From bc4d74995f2c874bd25447d995f6f0fea23e3a76 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 11 Sep 2026 18:42:47 +0200 Subject: [PATCH] feat: add member hard-delete and password reset flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE /api/members/:memberId — admin-only, erases the Matrix account, audit-logs before deleting (audit_logs.targetId is unconstrained text so the entry survives), relies on existing cascade FKs for team/role cleanup. Frontend: confirm dialog in the members table dropdown. - POST /api/auth/forgot-password + /api/auth/reset-password/:token — token-based reset via the existing mail.ts SMTP client, generic response regardless of whether the email is registered (no enumeration leak), 60min token TTL, single-use. Frontend: forgot/reset password pages, link added to the login form. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67 --- .../drizzle/0001_rare_mulholland_black.sql | 12 + backend/drizzle/meta/0001_snapshot.json | 1478 +++++++++++++++++ backend/drizzle/meta/_journal.json | 7 + backend/src/db/schema.ts | 15 + backend/src/routes/auth.ts | 79 +- backend/src/routes/members.ts | 45 + frontend/src/App.tsx | 4 + .../components/auth/ForgotPasswordForm.tsx | 91 + frontend/src/components/auth/LoginForm.tsx | 5 + .../src/components/auth/ResetPasswordForm.tsx | 86 + frontend/src/pages/ForgotPasswordPage.tsx | 9 + frontend/src/pages/MembersPage.tsx | 67 + frontend/src/pages/ResetPasswordPage.tsx | 9 + 13 files changed, 1905 insertions(+), 2 deletions(-) create mode 100644 backend/drizzle/0001_rare_mulholland_black.sql create mode 100644 backend/drizzle/meta/0001_snapshot.json create mode 100644 frontend/src/components/auth/ForgotPasswordForm.tsx create mode 100644 frontend/src/components/auth/ResetPasswordForm.tsx create mode 100644 frontend/src/pages/ForgotPasswordPage.tsx create mode 100644 frontend/src/pages/ResetPasswordPage.tsx diff --git a/backend/drizzle/0001_rare_mulholland_black.sql b/backend/drizzle/0001_rare_mulholland_black.sql new file mode 100644 index 0000000..78289ed --- /dev/null +++ b/backend/drizzle/0001_rare_mulholland_black.sql @@ -0,0 +1,12 @@ +CREATE TABLE "password_reset_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp NOT NULL, + "used_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "password_reset_tokens_token_unique" UNIQUE("token") +); +--> statement-breakpoint +ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USING btree ("user_id"); \ No newline at end of file diff --git a/backend/drizzle/meta/0001_snapshot.json b/backend/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..efc16cd --- /dev/null +++ b/backend/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1478 @@ +{ + "id": "7623ad60-f099-46c4-8027-42012f2d062f", + "prevId": "deed0d60-b691-42ed-b003-21e4b363a37a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_organization_id_idx": { + "name": "audit_logs_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_organization_id_organizations_id_fk": { + "name": "audit_logs_organization_id_organizations_id_fk", + "tableFrom": "audit_logs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_user_id_users_id_fk": { + "name": "audit_logs_actor_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_rooms": { + "name": "chat_rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "matrix_room_id": { + "name": "matrix_room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "chat_room_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_rooms_organization_id_idx": { + "name": "chat_rooms_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_rooms_organization_id_organizations_id_fk": { + "name": "chat_rooms_organization_id_organizations_id_fk", + "tableFrom": "chat_rooms", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_rooms_department_id_departments_id_fk": { + "name": "chat_rooms_department_id_departments_id_fk", + "tableFrom": "chat_rooms", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_rooms_team_id_teams_id_fk": { + "name": "chat_rooms_team_id_teams_id_fk", + "tableFrom": "chat_rooms", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_rooms_matrix_room_id_unique": { + "name": "chat_rooms_matrix_room_id_unique", + "nullsNotDistinct": false, + "columns": [ + "matrix_room_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.departments": { + "name": "departments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "departments_organization_id_idx": { + "name": "departments_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "departments_organization_id_organizations_id_fk": { + "name": "departments_organization_id_organizations_id_fk", + "tableFrom": "departments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_team_id_teams_id_fk": { + "name": "invitations_team_id_teams_id_fk", + "tableFrom": "invitations", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitations_token_unique": { + "name": "invitations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "birth_date": { + "name": "birth_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "matrix_user_id": { + "name": "matrix_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "members_organization_id_email_unique": { + "name": "members_organization_id_email_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_roles": { + "name": "org_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_roles_organization_id_idx": { + "name": "org_roles_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_roles_member_id_idx": { + "name": "org_roles_member_id_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_roles_organization_id_organizations_id_fk": { + "name": "org_roles_organization_id_organizations_id_fk", + "tableFrom": "org_roles", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_roles_member_id_members_id_fk": { + "name": "org_roles_member_id_members_id_fk", + "tableFrom": "org_roles", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_roles_department_id_departments_id_fk": { + "name": "org_roles_department_id_departments_id_fk", + "tableFrom": "org_roles", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_roles_scope_unique": { + "name": "org_roles_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "member_id", + "department_id", + "role" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parent_links": { + "name": "parent_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_member_id": { + "name": "child_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_member_id": { + "name": "parent_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "parent_links_child_member_id_members_id_fk": { + "name": "parent_links_child_member_id_members_id_fk", + "tableFrom": "parent_links", + "tableTo": "members", + "columnsFrom": [ + "child_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "parent_links_parent_member_id_members_id_fk": { + "name": "parent_links_parent_member_id_members_id_fk", + "tableFrom": "parent_links", + "tableTo": "members", + "columnsFrom": [ + "parent_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "parent_links_child_parent_unique": { + "name": "parent_links_child_parent_unique", + "nullsNotDistinct": false, + "columns": [ + "child_member_id", + "parent_member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "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 + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_reset_tokens_user_id_idx": { + "name": "password_reset_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_tokens_token_unique": { + "name": "password_reset_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seasons": { + "name": "seasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "seasons_organization_id_idx": { + "name": "seasons_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seasons_organization_id_organizations_id_fk": { + "name": "seasons_organization_id_organizations_id_fk", + "tableFrom": "seasons", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_jobs": { + "name": "sync_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sync_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "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": { + "sync_jobs_organization_id_idx": { + "name": "sync_jobs_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_jobs_status_idx": { + "name": "sync_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_jobs_organization_id_organizations_id_fk": { + "name": "sync_jobs_organization_id_organizations_id_fk", + "tableFrom": "sync_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_member_id_idx": { + "name": "team_members_member_id_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_member_id_members_id_fk": { + "name": "team_members_member_id_members_id_fk", + "tableFrom": "team_members", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_members_team_id_member_id_role_unique": { + "name": "team_members_team_id_member_id_role_unique", + "nullsNotDistinct": false, + "columns": [ + "team_id", + "member_id", + "role" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_department_id_idx": { + "name": "teams_department_id_idx", + "columns": [ + { + "expression": "department_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_department_id_departments_id_fk": { + "name": "teams_department_id_departments_id_fk", + "tableFrom": "teams", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_season_id_seasons_id_fk": { + "name": "teams_season_id_seasons_id_fk", + "tableFrom": "teams", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "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()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.chat_room_scope": { + "name": "chat_room_scope", + "schema": "public", + "values": [ + "organization", + "department", + "team" + ] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "revoked", + "expired" + ] + }, + "public.member_status": { + "name": "member_status", + "schema": "public", + "values": [ + "active", + "suspended", + "left" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "ORG_ADMIN", + "ORG_MANAGER", + "DEPARTMENT_ADMIN", + "TEAM_MANAGER", + "TEAM_LEAD", + "ASSISTANT_LEAD", + "SUPERVISOR", + "MEMBER", + "GUARDIAN" + ] + }, + "public.sync_job_status": { + "name": "sync_job_status", + "schema": "public", + "values": [ + "pending", + "processing", + "done", + "failed" + ] + } + }, + "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 b0b8b7a..16df185 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1789131255612, "tag": "0000_nebulous_prism", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1789144826183, + "tag": "0001_rare_mulholland_black", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 8531a27..797607e 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -17,6 +17,21 @@ export const users = pgTable("users", { createdAt: timestamp("created_at").notNull().defaultNow(), }); +export const passwordResetTokens = pgTable( + "password_reset_tokens", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + token: text("token").notNull().unique(), + expiresAt: timestamp("expires_at").notNull(), + usedAt: timestamp("used_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => [index("password_reset_tokens_user_id_idx").on(table.userId)], +); + export const orgRoleEnum = pgEnum("org_role", [ "ORG_ADMIN", "ORG_MANAGER", diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index a841695..8050121 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,15 +1,28 @@ +import { randomBytes } from "node:crypto"; import type { FastifyInstance } from "fastify"; import bcrypt from "bcryptjs"; import { z } from "zod"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { db } from "../db/index.js"; -import { users } from "../db/schema.js"; +import { users, passwordResetTokens } from "../db/schema.js"; +import { sendMail } from "../services/mail.js"; const credentialsSchema = z.object({ email: z.string().email(), password: z.string().min(8), }); +const appBaseUrl = process.env.APP_BASE_URL ?? "http://localhost:5173"; +const RESET_TOKEN_TTL_MINUTES = 60; + +const forgotPasswordSchema = z.object({ + email: z.string().email(), +}); + +const resetPasswordSchema = z.object({ + password: z.string().min(8), +}); + export async function authRoutes(app: FastifyInstance) { app.post("/api/auth/register", { config: { rateLimit: { max: 5, timeWindow: "1 minute" } }, @@ -57,4 +70,66 @@ export async function authRoutes(app: FastifyInstance) { const { sub, email } = request.user; return { id: sub, email }; }); + + app.post( + "/api/auth/forgot-password", + { config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, + async (request, reply) => { + const parsed = forgotPasswordSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { email } = parsed.data; + + // Always return the same generic response — never reveal whether the email is registered. + const [user] = await db.select({ id: users.id }).from(users).where(eq(users.email, email)).limit(1); + if (user) { + const token = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000); + await db.insert(passwordResetTokens).values({ userId: user.id, token, expiresAt }); + + const resetLink = `${appBaseUrl}/reset-password/${token}`; + await sendMail({ + to: email, + subject: "Passwort zurücksetzen — TalkTime", + text: `Passwort zurücksetzen: ${resetLink}\n\nLink gültig für ${RESET_TOKEN_TTL_MINUTES} Minuten. Falls du das nicht angefordert hast, ignoriere diese E-Mail.`, + html: `

Passwort zurücksetzen

Link gültig für ${RESET_TOKEN_TTL_MINUTES} Minuten. Falls du das nicht angefordert hast, ignoriere diese E-Mail.

`, + }); + } + + return reply.send({ message: "Falls die E-Mail-Adresse registriert ist, wurde ein Link verschickt." }); + }, + ); + + app.post( + "/api/auth/reset-password/:token", + { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (request, reply) => { + const { token } = request.params as { token: string }; + const parsed = resetPasswordSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0].message }); + } + const { password } = parsed.data; + + const [resetToken] = await db + .select() + .from(passwordResetTokens) + .where(and(eq(passwordResetTokens.token, token), isNull(passwordResetTokens.usedAt))) + .limit(1); + + if (!resetToken || resetToken.expiresAt < new Date()) { + return reply.code(410).send({ error: "Reset link is invalid or has expired" }); + } + + const passwordHash = await bcrypt.hash(password, 12); + await db.update(users).set({ passwordHash }).where(eq(users.id, resetToken.userId)); + await db + .update(passwordResetTokens) + .set({ usedAt: new Date() }) + .where(eq(passwordResetTokens.id, resetToken.id)); + + return reply.send({ message: "Password updated" }); + }, + ); } diff --git a/backend/src/routes/members.ts b/backend/src/routes/members.ts index 50dc546..0468ed1 100644 --- a/backend/src/routes/members.ts +++ b/backend/src/routes/members.ts @@ -98,4 +98,49 @@ export async function memberRoutes(app: FastifyInstance) { return reply.send({ id: memberId, status }); }, ); + + app.delete("/api/members/:memberId", { preHandler: app.authenticate }, async (request, reply) => { + const { memberId } = request.params as { memberId: string }; + + const [member] = await db + .select({ + organizationId: members.organizationId, + matrixUserId: members.matrixUserId, + firstName: members.firstName, + lastName: members.lastName, + }) + .from(members) + .where(eq(members.id, memberId)) + .limit(1); + if (!member) { + return reply.code(404).send({ error: "Member not found" }); + } + + const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, member.organizationId); + if (!isAdmin) { + return reply.code(403).send({ error: "Forbidden" }); + } + + // Audit log first: targetId is unconstrained text, so the entry survives the delete below. + await logAudit({ + organizationId: member.organizationId, + actorUserId: request.user.sub, + action: "member.deleted", + targetType: "member", + targetId: memberId, + metadata: { firstName: member.firstName, lastName: member.lastName }, + }); + + if (member.matrixUserId) { + try { + await deactivateUser(member.matrixUserId, true); + } catch (err) { + console.warn(`[synapse] failed to erase matrix user for member ${memberId}:`, err); + } + } + + await db.delete(members).where(eq(members.id, memberId)); + + return reply.code(204).send(); + }); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c26ab64..a8831ee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,8 @@ import { OrganizationProvider } from "@/lib/organization-context"; import { ProtectedRoute } from "@/routes/ProtectedRoute"; import { LoginPage } from "@/pages/LoginPage"; import { RegisterPage } from "@/pages/RegisterPage"; +import { ForgotPasswordPage } from "@/pages/ForgotPasswordPage"; +import { ResetPasswordPage } from "@/pages/ResetPasswordPage"; import { DashboardPage } from "@/pages/DashboardPage"; import { MembersPage } from "@/pages/MembersPage"; import { DepartmentsPage } from "@/pages/DepartmentsPage"; @@ -21,6 +23,8 @@ function App() { } /> } /> + } /> + } /> } /> }> } /> diff --git a/frontend/src/components/auth/ForgotPasswordForm.tsx b/frontend/src/components/auth/ForgotPasswordForm.tsx new file mode 100644 index 0000000..b451c0c --- /dev/null +++ b/frontend/src/components/auth/ForgotPasswordForm.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +const forgotPasswordSchema = z.object({ + email: z.string().email("Bitte gültige E-Mail eingeben"), +}); + +type ForgotPasswordValues = z.infer; + +export function ForgotPasswordForm() { + const [formError, setFormError] = useState(null); + const [sent, setSent] = useState(false); + + const form = useForm({ + resolver: zodResolver(forgotPasswordSchema), + defaultValues: { email: "" }, + }); + + async function onSubmit(values: ForgotPasswordValues) { + setFormError(null); + try { + const res = await fetch("/api/auth/forgot-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + throw new Error(body.error ?? `Fehler (${res.status})`); + } + setSent(true); + } catch (err) { + setFormError((err as Error).message); + } + } + + return ( + + + Passwort vergessen + + + {sent ? ( +

+ Falls die E-Mail-Adresse registriert ist, wurde ein Link zum Zurücksetzen verschickt. +

+ ) : ( +
+ + ( + + E-Mail + + + + + + )} + /> + {formError &&

{formError}

} + + + + )} +

+ + Zurück zum Login + +

+
+
+ ); +} diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx index 2cfcd58..7f4d4ce 100644 --- a/frontend/src/components/auth/LoginForm.tsx +++ b/frontend/src/components/auth/LoginForm.tsx @@ -85,6 +85,11 @@ export function LoginForm() { +

+ + Passwort vergessen? + +

Noch kein Account?{" "} diff --git a/frontend/src/components/auth/ResetPasswordForm.tsx b/frontend/src/components/auth/ResetPasswordForm.tsx new file mode 100644 index 0000000..7b04302 --- /dev/null +++ b/frontend/src/components/auth/ResetPasswordForm.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useNavigate, useParams } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +const resetPasswordSchema = z.object({ + password: z.string().min(8, "Mindestens 8 Zeichen"), +}); + +type ResetPasswordValues = z.infer; + +export function ResetPasswordForm() { + const { token } = useParams<{ token: string }>(); + const navigate = useNavigate(); + const [formError, setFormError] = useState(null); + + const form = useForm({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { password: "" }, + }); + + async function onSubmit(values: ResetPasswordValues) { + setFormError(null); + try { + const res = await fetch(`/api/auth/reset-password/${token}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + throw new Error( + res.status === 410 + ? "Link ist ungültig oder abgelaufen. Bitte neuen Link anfordern." + : body.error ?? `Fehler (${res.status})`, + ); + } + navigate("/login"); + } catch (err) { + setFormError((err as Error).message); + } + } + + return ( + + + Neues Passwort setzen + + +

+ + ( + + Neues Passwort + + + + + + )} + /> + {formError &&

{formError}

} + + + + + + ); +} diff --git a/frontend/src/pages/ForgotPasswordPage.tsx b/frontend/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 0000000..08dbf6d --- /dev/null +++ b/frontend/src/pages/ForgotPasswordPage.tsx @@ -0,0 +1,9 @@ +import { ForgotPasswordForm } from "@/components/auth/ForgotPasswordForm"; + +export function ForgotPasswordPage() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/pages/MembersPage.tsx b/frontend/src/pages/MembersPage.tsx index fb08b58..b36e137 100644 --- a/frontend/src/pages/MembersPage.tsx +++ b/frontend/src/pages/MembersPage.tsx @@ -16,6 +16,16 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { MoreHorizontal } from "lucide-react"; import { useAuth } from "@/lib/auth-context"; import { useOrganization } from "@/lib/organization-context"; @@ -38,6 +48,8 @@ export function MembersPage() { const { activeOrganizationId } = useOrganization(); const [page, setPage] = useState(0); const [statusError, setStatusError] = useState(null); + const [memberToDelete, setMemberToDelete] = useState(null); + const [deleting, setDeleting] = useState(false); const { members, total, loading, error, refresh } = useMembers(token, activeOrganizationId, page); @@ -61,6 +73,28 @@ export function MembersPage() { } } + async function onDeleteConfirmed() { + if (!memberToDelete) return; + setStatusError(null); + setDeleting(true); + try { + const res = await fetch(`/api/members/${memberToDelete.id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok && res.status !== 204) { + const body = (await res.json()) as { error?: string }; + throw new Error(body.error ?? `Fehler (${res.status})`); + } + setMemberToDelete(null); + await refresh(); + } catch (err) { + setStatusError((err as Error).message); + } finally { + setDeleting(false); + } + } + return (
@@ -139,6 +173,12 @@ export function MembersPage() { Austritt vermerken )} + setMemberToDelete(member)} + > + Löschen + @@ -171,6 +211,33 @@ export function MembersPage() {
)} + + !open && setMemberToDelete(null)}> + + + Mitglied endgültig löschen? + + {memberToDelete && ( + <> + {memberToDelete.firstName} {memberToDelete.lastName} wird unwiderruflich gelöscht, + inklusive Team-Zuordnungen und Rollen. Der Matrix-Account wird deaktiviert und + gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. + + )} + + + + Abbrechen + + {deleting ? "Löscht…" : "Endgültig löschen"} + + + +
); } diff --git a/frontend/src/pages/ResetPasswordPage.tsx b/frontend/src/pages/ResetPasswordPage.tsx new file mode 100644 index 0000000..cad992c --- /dev/null +++ b/frontend/src/pages/ResetPasswordPage.tsx @@ -0,0 +1,9 @@ +import { ResetPasswordForm } from "@/components/auth/ResetPasswordForm"; + +export function ResetPasswordPage() { + return ( +
+ +
+ ); +}