diff --git a/RJLResaka/README_IMPORT.txt b/RJLResaka/README_IMPORT.txt new file mode 100644 index 0000000..2d731e2 --- /dev/null +++ b/RJLResaka/README_IMPORT.txt @@ -0,0 +1,41 @@ +RJLResaka - Dynamic Web Project (Tomcat 9) + +Objectif: +- Application web de discussion privée style Facebook Messenger +- Java JEE (Servlet/JSP) + MySQL Workbench +- Projet Dynamic Web Project sans Maven + +Structure actuellement livrée: +- src/com/rjlresaka/model -> modèles Java +- src/com/rjlresaka/dao -> accès MySQL (users + reset password) +- src/com/rjlresaka/servlet -> Home, Register, Login, ForgotPassword, ResetPassword, Dashboard, Logout +- src/com/rjlresaka/filter -> filtre UTF-8 + protection des pages privées +- src/com/rjlresaka/util -> connexion MySQL, BCrypt, génération de token +- WebContent/WEB-INF/views -> JSP protégées +- WebContent/assets -> design moderne clair responsive +- database/rjlresaka.sql -> schéma MySQL complet prêt pour Workbench + +JARs à placer dans WebContent/WEB-INF/lib: +- jbcrypt-0.4.jar +- mysql-connector-java-8.0.18.jar +- javax.mail-api-1.6.2.jar +- jstl-1.2.jar + +Fonctionnalités déjà codées dans cette étape: +1. Page d'accueil moderne +2. Inscription +3. Connexion +4. Mot de passe oublié (génération de token) +5. Réinitialisation du mot de passe +6. Tableau de bord avec liste des autres utilisateurs + +Configuration MySQL à adapter dans WebContent/WEB-INF/web.xml: +- db.url +- db.user +- db.password + +Étapes suivantes recommandées: +1. Vraies conversations privées 1-1 +2. Envoi / modification / suppression de messages +3. Upload image/fichier + téléchargement +4. Emojis, réactions, badge non lus diff --git a/RJLResaka/WebContent/WEB-INF/views/auth/forgot-password.jsp b/RJLResaka/WebContent/WEB-INF/views/auth/forgot-password.jsp new file mode 100644 index 0000000..043e8c9 --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/views/auth/forgot-password.jsp @@ -0,0 +1,52 @@ +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> + + + + + + Mot de passe oublié | RJLResaka + + + + + + + +
+
+ ← Retour + Réinitialisation +

Mot de passe oublié

+

Entrez votre email. Pour l'instant, le lien est affiché à l'écran; ensuite vous pourrez brancher JavaMail.

+ + <% if (request.getAttribute("error") != null) { %> +
<%= request.getAttribute("error") %>
+ <% } %> + <% if (request.getAttribute("success") != null) { %> +
<%= request.getAttribute("success") %>
+ <% } %> + +
+ + +
+ + <% if (request.getAttribute("resetLink") != null) { %> +
+ Lien généré :
+ <%= request.getAttribute("resetLink") %>

+ Token : <%= request.getAttribute("generatedToken") %> +
+ <% } %> + + <% if (request.getAttribute("debugMessage") != null) { %> +

Détail technique: <%= request.getAttribute("debugMessage") %>

+ <% } %> +
+
+ + + diff --git a/RJLResaka/WebContent/WEB-INF/views/auth/login.jsp b/RJLResaka/WebContent/WEB-INF/views/auth/login.jsp new file mode 100644 index 0000000..d287e4f --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/views/auth/login.jsp @@ -0,0 +1,53 @@ +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> + + + + + + Connexion | RJLResaka + + + + + + + +
+
+ ← Retour + Connexion +

Bon retour sur RJLResaka

+

Utilisez votre email ou votre nom d'utilisateur pour continuer.

+ + <% if (request.getAttribute("error") != null) { %> +
<%= request.getAttribute("error") %>
+ <% } %> + <% if (request.getAttribute("success") != null) { %> +
<%= request.getAttribute("success") %>
+ <% } %> + +
+ + + +
+ + + + <% if (request.getAttribute("debugMessage") != null) { %> +

Détail technique: <%= request.getAttribute("debugMessage") %>

+ <% } %> +
+
+ + + diff --git a/RJLResaka/WebContent/WEB-INF/views/auth/register.jsp b/RJLResaka/WebContent/WEB-INF/views/auth/register.jsp new file mode 100644 index 0000000..7ebc059 --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/views/auth/register.jsp @@ -0,0 +1,63 @@ +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> + + + + + + Inscription | RJLResaka + + + + + + + +
+
+ ← Retour + Inscription +

Créez votre compte

+

Une seule inscription suffit pour commencer vos discussions privées.

+ + <% if (request.getAttribute("error") != null) { %> +
<%= request.getAttribute("error") %>
+ <% } %> + +
+ + + + + +
+ +
+
+ + + + <% if (request.getAttribute("debugMessage") != null) { %> +

Détail technique: <%= request.getAttribute("debugMessage") %>

+ <% } %> +
+
+ + + diff --git a/RJLResaka/WebContent/WEB-INF/views/auth/reset-password.jsp b/RJLResaka/WebContent/WEB-INF/views/auth/reset-password.jsp new file mode 100644 index 0000000..d3f05c3 --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/views/auth/reset-password.jsp @@ -0,0 +1,55 @@ +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> +<% + String token = request.getParameter("token"); + if (request.getAttribute("token") != null) { + token = (String) request.getAttribute("token"); + } +%> + + + + + + Nouveau mot de passe | RJLResaka + + + + + + + +
+
+ ← Retour + Nouveau mot de passe +

Réinitialisez votre mot de passe

+

Collez le token reçu ou ouvrez directement le lien généré.

+ + <% if (request.getAttribute("error") != null) { %> +
<%= request.getAttribute("error") %>
+ <% } %> + +
+ + + + +
+ + <% if (request.getAttribute("debugMessage") != null) { %> +

Détail technique: <%= request.getAttribute("debugMessage") %>

+ <% } %> +
+
+ + + diff --git a/RJLResaka/WebContent/WEB-INF/views/dashboard.jsp b/RJLResaka/WebContent/WEB-INF/views/dashboard.jsp new file mode 100644 index 0000000..c9bf45c --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/views/dashboard.jsp @@ -0,0 +1,77 @@ +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> + + + + + + Tableau de bord | RJLResaka + + + + + + + +
+ + +
+
+
+ Utilisateurs +

Choisissez une personne à qui parler

+
+ ${fn:length(users)} compte(s) +
+ + +
${error}
+
+ +
+ +
+
${item.initials}
+

${item.fullName}

+

@${item.username}

+ ${item.email} + +
+
+ + +
+

Aucun autre utilisateur pour le moment

+

Créez un deuxième compte pour tester le chat privé ensuite.

+
+
+
+ + +

Détail technique: ${debugMessage}

+
+
+
+ + + diff --git a/RJLResaka/WebContent/WEB-INF/views/home.jsp b/RJLResaka/WebContent/WEB-INF/views/home.jsp new file mode 100644 index 0000000..f8dfbd6 --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/views/home.jsp @@ -0,0 +1,56 @@ +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> + + + + + + RJLResaka | Discussion privée moderne en Java JEE + + + + + + + +
+
+
+ RJLResaka • Java JEE +

Discutez comme sur Messenger, dans votre propre application web.

+

+ Inscription, connexion, mot de passe oublié, liste d'utilisateurs, discussion privée, + design clair et responsive. La prochaine étape ajoute les vraies conversations, + les pièces jointes et les réactions. +

+ +
    +
  • Tomcat 9 + Servlet/JSP
  • +
  • MySQL Workbench
  • +
  • Base prête pour chat privé + fichiers
  • +
+
+ +
+
+ + + diff --git a/RJLResaka/WebContent/WEB-INF/web.xml b/RJLResaka/WebContent/WEB-INF/web.xml new file mode 100644 index 0000000..37d9c91 --- /dev/null +++ b/RJLResaka/WebContent/WEB-INF/web.xml @@ -0,0 +1,33 @@ + + + + RJLResaka + + + db.driver + com.mysql.cj.jdbc.Driver + + + db.url + jdbc:mysql://127.0.0.1:3306/rjlresaka?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC + + + db.user + root + + + db.password + + + + + index.jsp + + + + 60 + + diff --git a/RJLResaka/WebContent/assets/css/app.css b/RJLResaka/WebContent/assets/css/app.css new file mode 100644 index 0000000..fd188c3 --- /dev/null +++ b/RJLResaka/WebContent/assets/css/app.css @@ -0,0 +1,316 @@ +* { box-sizing: border-box; } +:root { + --bg: #eef6ff; + --bg-soft: #f8fbff; + --text: #16324f; + --muted: #5a728e; + --line: rgba(140, 174, 205, 0.32); + --primary: #1d9bf0; + --primary-dark: #0f6fcc; + --accent: #0fd4c2; + --white: #ffffff; + --shadow: 0 25px 60px rgba(30, 80, 120, 0.16); +} +html { scroll-behavior: smooth; } +body { + margin: 0; + min-height: 100vh; + font-family: 'Inter', Arial, sans-serif; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(15, 212, 194, 0.16), transparent 28%), + radial-gradient(circle at bottom right, rgba(29, 155, 240, 0.18), transparent 24%), + linear-gradient(135deg, var(--bg) 0%, var(--bg-soft) 48%, #ffffff 100%); +} +a { color: inherit; text-decoration: none; } +button, input { font: inherit; } +.glass { + background: rgba(255, 255, 255, 0.76); + backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.64); + box-shadow: var(--shadow); +} +.hero-badge, .pill { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 999px; + padding: 10px 16px; + background: rgba(29, 155, 240, 0.12); + color: var(--primary-dark); + font-weight: 700; + font-size: 14px; +} +.button { + display: inline-flex; + justify-content: center; + align-items: center; + gap: 10px; + padding: 14px 22px; + border-radius: 18px; + border: none; + cursor: pointer; + transition: transform .2s ease, box-shadow .2s ease, background .2s ease; + box-shadow: 0 12px 26px rgba(29, 155, 240, 0.18); +} +.button:hover { transform: translateY(-2px); } +.button.primary { + color: var(--white); + background: linear-gradient(135deg, var(--primary), var(--accent)); +} +.button.secondary { + color: var(--primary-dark); + background: rgba(255, 255, 255, 0.95); + border: 1px solid rgba(29, 155, 240, 0.16); + box-shadow: none; +} +.ghost-link { + color: var(--primary-dark); + font-weight: 600; +} +.hero-title, h1, h2, h3 { letter-spacing: -0.04em; } +.hero-title { font-size: clamp(2.6rem, 6vw, 4.6rem); line-height: .95; margin: 18px 0; } +.hero-copy, .auth-copy, p, small { color: var(--muted); line-height: 1.7; } +.landing-shell, .auth-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px; +} +.hero-card { + width: min(1180px, 100%); + border-radius: 36px; + padding: 36px; + background: rgba(255,255,255,0.82); + box-shadow: var(--shadow); + border: 1px solid rgba(255,255,255,0.75); +} +.hero-grid { + display: grid; + grid-template-columns: 1.15fr .85fr; + gap: 28px; + align-items: center; +} +.hero-actions, .button-row { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-top: 24px; +} +.hero-list { + display: flex; + flex-wrap: wrap; + gap: 14px; + list-style: none; + padding: 0; + margin: 26px 0 0; +} +.hero-list li { + background: rgba(255,255,255,0.88); + border: 1px solid var(--line); + border-radius: 18px; + padding: 12px 16px; + font-weight: 600; +} +.preview-card { + display: flex; + justify-content: center; +} +.preview-window { + width: min(420px, 100%); + padding: 20px; + border-radius: 30px; + background: linear-gradient(180deg, #fdfefe 0%, #edf7ff 100%); + border: 1px solid rgba(29, 155, 240, 0.16); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.85), 0 35px 50px rgba(35, 97, 148, 0.18); +} +.preview-topbar { display: flex; gap: 8px; margin-bottom: 18px; } +.preview-topbar span { + width: 10px; + height: 10px; + border-radius: 50%; + background: rgba(22, 50, 79, 0.18); +} +.preview-chat { + padding: 18px; + border-radius: 24px; + background: rgba(255,255,255,0.88); + border: 1px solid rgba(29, 155, 240, 0.14); +} +.preview-bubble { + max-width: 78%; + padding: 14px 16px; + border-radius: 18px; + margin-bottom: 12px; + font-weight: 500; +} +.preview-bubble.incoming { + background: #f3f7fb; + color: var(--text); +} +.preview-bubble.outgoing { + margin-left: auto; + background: linear-gradient(135deg, var(--primary), var(--accent)); + color: white; +} +.preview-users { display: flex; gap: 10px; margin-top: 16px; } +.mini-user, .avatar { + display: inline-grid; + place-items: center; + border-radius: 50%; + color: white; + font-weight: 800; +} +.mini-user { + width: 42px; + height: 42px; + background: rgba(29, 155, 240, 0.72); +} +.mini-user.active { background: linear-gradient(135deg, var(--primary), var(--accent)); } +.auth-panel { + width: min(520px, 100%); + padding: 34px; + border-radius: 28px; +} +.auth-panel.wide { width: min(720px, 100%); } +.stack-form { + display: grid; + gap: 16px; + margin-top: 24px; +} +.stack-form.two-col { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.stack-form label { + display: grid; + gap: 8px; + font-weight: 600; +} +.stack-form label span { font-size: 14px; } +.stack-form input { + width: 100%; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid rgba(29, 155, 240, 0.14); + background: rgba(255,255,255,0.92); + outline: none; +} +.stack-form input:focus { + border-color: rgba(29, 155, 240, 0.52); + box-shadow: 0 0 0 4px rgba(29, 155, 240, 0.12); +} +.auth-links { + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 18px; + font-weight: 600; + color: var(--primary-dark); +} +.auth-links.left { justify-content: flex-start; } +.alert { + border-radius: 18px; + padding: 14px 16px; + margin-top: 18px; + font-weight: 600; +} +.alert.error { + color: #a33434; + background: rgba(255, 92, 92, 0.12); + border: 1px solid rgba(255, 92, 92, 0.2); +} +.alert.success { + color: #0b7a61; + background: rgba(13, 217, 168, 0.12); + border: 1px solid rgba(13, 217, 168, 0.22); +} +.debug-note, .token-box { + margin-top: 18px; + padding: 14px 16px; + border-radius: 18px; + background: rgba(22, 50, 79, 0.06); + color: var(--muted); + overflow-wrap: anywhere; +} +.dashboard-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + gap: 22px; + padding: 22px; +} +.sidebar, .content-panel { + border-radius: 28px; + padding: 24px; +} +.sidebar-top, .content-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; +} +.profile-card { + display: grid; + grid-template-columns: 76px 1fr; + gap: 16px; + align-items: center; + padding: 22px 0; +} +.avatar { + width: 72px; + height: 72px; + font-size: 24px; +} +.avatar.large { + width: 64px; + height: 64px; + font-size: 20px; + margin: 0 auto 12px; +} +.profile-card h1, .user-card h3, .empty-card h3 { margin: 0 0 6px; } +.profile-card p, .user-card p { margin: 0 0 4px; } +.panel-note { + border-top: 1px solid var(--line); + padding-top: 20px; + color: var(--muted); + line-height: 1.7; +} +.section-kicker { + font-weight: 700; + color: var(--primary-dark); + text-transform: uppercase; + letter-spacing: .08em; + font-size: 12px; +} +.content-head h2 { margin: 8px 0 0; font-size: clamp(2rem, 3vw, 2.8rem); } +.user-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 18px; + margin-top: 24px; +} +.user-card, .empty-card { + border-radius: 24px; + padding: 22px; + background: rgba(255,255,255,0.88); + border: 1px solid var(--line); + text-align: center; +} +.empty-card { + text-align: left; + grid-column: 1 / -1; +} +@media (max-width: 980px) { + .hero-grid, + .dashboard-shell, + .stack-form.two-col { + grid-template-columns: 1fr; + } + .dashboard-shell { padding: 16px; } +} +@media (max-width: 640px) { + .landing-shell, .auth-shell { padding: 18px; } + .hero-card, .auth-panel, .sidebar, .content-panel { padding: 22px; border-radius: 24px; } + .hero-title { font-size: 2.4rem; } + .auth-links, .sidebar-top, .content-head { flex-direction: column; align-items: flex-start; } +} diff --git a/RJLResaka/WebContent/assets/js/app.js b/RJLResaka/WebContent/assets/js/app.js new file mode 100644 index 0000000..6aec255 --- /dev/null +++ b/RJLResaka/WebContent/assets/js/app.js @@ -0,0 +1,11 @@ +document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('.alert').forEach(function (alert) { + setTimeout(function () { + alert.style.opacity = '0'; + alert.style.transform = 'translateY(-6px)'; + setTimeout(function () { + alert.style.display = 'none'; + }, 220); + }, 4200); + }); +}); diff --git a/RJLResaka/WebContent/index.jsp b/RJLResaka/WebContent/index.jsp new file mode 100644 index 0000000..656fdc1 --- /dev/null +++ b/RJLResaka/WebContent/index.jsp @@ -0,0 +1,4 @@ +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> +<% + response.sendRedirect(request.getContextPath() + "/home"); +%> diff --git a/RJLResaka/database/rjlresaka.sql b/RJLResaka/database/rjlresaka.sql new file mode 100644 index 0000000..532a1ff --- /dev/null +++ b/RJLResaka/database/rjlresaka.sql @@ -0,0 +1,94 @@ +-- RJLResaka MySQL schema +-- Compatible with MySQL Workbench / MariaDB +-- Create the database then import this file. + +CREATE DATABASE IF NOT EXISTS rjlresaka + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE rjlresaka; + +CREATE TABLE IF NOT EXISTS users ( + id INT PRIMARY KEY AUTO_INCREMENT, + full_name VARCHAR(120) NOT NULL, + username VARCHAR(60) NOT NULL UNIQUE, + email VARCHAR(120) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + avatar_color VARCHAR(20) NOT NULL DEFAULT '#0ea5e9', + bio VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, + token VARCHAR(120) NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + used TINYINT(1) NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_password_reset_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS conversations ( + id INT PRIMARY KEY AUTO_INCREMENT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS conversation_participants ( + id INT PRIMARY KEY AUTO_INCREMENT, + conversation_id INT NOT NULL, + user_id INT NOT NULL, + joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_conv_part_conversation + FOREIGN KEY (conversation_id) REFERENCES conversations(id) + ON DELETE CASCADE, + CONSTRAINT fk_conv_part_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE, + CONSTRAINT uq_conv_part UNIQUE (conversation_id, user_id) +); + +CREATE TABLE IF NOT EXISTS messages ( + id INT PRIMARY KEY AUTO_INCREMENT, + conversation_id INT NOT NULL, + sender_id INT NOT NULL, + body TEXT NULL, + attachment_name VARCHAR(255) NULL, + attachment_path VARCHAR(255) NULL, + attachment_type VARCHAR(120) NULL, + attachment_size BIGINT NULL, + is_edited TINYINT(1) NOT NULL DEFAULT 0, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + seen_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_message_conversation + FOREIGN KEY (conversation_id) REFERENCES conversations(id) + ON DELETE CASCADE, + CONSTRAINT fk_message_sender + FOREIGN KEY (sender_id) REFERENCES users(id) + ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS message_reactions ( + id INT PRIMARY KEY AUTO_INCREMENT, + message_id INT NOT NULL, + user_id INT NOT NULL, + emoji VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_reaction_message + FOREIGN KEY (message_id) REFERENCES messages(id) + ON DELETE CASCADE, + CONSTRAINT fk_reaction_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE, + CONSTRAINT uq_message_reaction UNIQUE (message_id, user_id, emoji) +); + +INSERT INTO users (full_name, username, email, password_hash, avatar_color, bio) +SELECT 'Demo User', 'demo', 'demo@rjlresaka.app', '$2a$10$u6N8G6s8wWC4b7A9iI5L8e2ZfQFlA95zT4zWS3TzFmpXQxwCLWv0W', '#2563eb', 'Compte de démonstration' +WHERE NOT EXISTS (SELECT 1 FROM users WHERE email = 'demo@rjlresaka.app'); diff --git a/RJLResaka/docs/INSTALL_JARS.txt b/RJLResaka/docs/INSTALL_JARS.txt new file mode 100644 index 0000000..1b037a9 --- /dev/null +++ b/RJLResaka/docs/INSTALL_JARS.txt @@ -0,0 +1,15 @@ +Placez ces fichiers dans: RJLResaka/WebContent/WEB-INF/lib + +Obligatoires pour l'étape actuelle: +- jbcrypt-0.4.jar +- mysql-connector-java-8.0.18.jar +- jstl-1.2.jar + +Pour la future étape email: +- javax.mail-api-1.6.2.jar + +Conseils Eclipse / Tomcat 9: +1. Right click project -> Properties -> Java Build Path -> Libraries +2. Vérifiez que WEB-INF/lib est bien dans Deployment Assembly +3. Importez database/rjlresaka.sql dans MySQL Workbench +4. Modifiez web.xml si votre mot de passe MySQL n'est pas vide diff --git a/RJLResaka/src/com/rjlresaka/dao/PasswordResetDAO.java b/RJLResaka/src/com/rjlresaka/dao/PasswordResetDAO.java new file mode 100644 index 0000000..b4d27e6 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/dao/PasswordResetDAO.java @@ -0,0 +1,49 @@ +package com.rjlresaka.dao; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; + +import javax.servlet.ServletContext; + +import com.rjlresaka.util.DatabaseConnection; + +public class PasswordResetDAO { + + public void createToken(int userId, String token, Timestamp expiresAt, ServletContext context) throws SQLException, ClassNotFoundException { + String cleanup = "UPDATE password_reset_tokens SET used = 1 WHERE user_id = ? AND used = 0"; + String insert = "INSERT INTO password_reset_tokens (user_id, token, expires_at, used) VALUES (?, ?, ?, 0)"; + try (Connection connection = DatabaseConnection.getConnection(context); + PreparedStatement cleanupStatement = connection.prepareStatement(cleanup); + PreparedStatement insertStatement = connection.prepareStatement(insert)) { + cleanupStatement.setInt(1, userId); + cleanupStatement.executeUpdate(); + insertStatement.setInt(1, userId); + insertStatement.setString(2, token); + insertStatement.setTimestamp(3, expiresAt); + insertStatement.executeUpdate(); + } + } + + public Integer findValidUserIdByToken(String token, ServletContext context) throws SQLException, ClassNotFoundException { + String sql = "SELECT user_id FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at >= NOW() LIMIT 1"; + try (Connection connection = DatabaseConnection.getConnection(context); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, token); + try (ResultSet rs = statement.executeQuery()) { + return rs.next() ? Integer.valueOf(rs.getInt("user_id")) : null; + } + } + } + + public void markAsUsed(String token, ServletContext context) throws SQLException, ClassNotFoundException { + String sql = "UPDATE password_reset_tokens SET used = 1 WHERE token = ?"; + try (Connection connection = DatabaseConnection.getConnection(context); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, token); + statement.executeUpdate(); + } + } +} diff --git a/RJLResaka/src/com/rjlresaka/dao/UserDAO.java b/RJLResaka/src/com/rjlresaka/dao/UserDAO.java new file mode 100644 index 0000000..e6e8ff6 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/dao/UserDAO.java @@ -0,0 +1,121 @@ +package com.rjlresaka.dao; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +import javax.servlet.ServletContext; + +import com.rjlresaka.model.User; +import com.rjlresaka.util.DatabaseConnection; + +public class UserDAO { + + public boolean emailExists(String email, ServletContext context) throws SQLException, ClassNotFoundException { + String sql = "SELECT id FROM users WHERE email = ?"; + try (Connection connection = DatabaseConnection.getConnection(context); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, email); + try (ResultSet rs = statement.executeQuery()) { + return rs.next(); + } + } + } + + public boolean usernameExists(String username, ServletContext context) throws SQLException, ClassNotFoundException { + String sql = "SELECT id FROM users WHERE username = ?"; + try (Connection connection = DatabaseConnection.getConnection(context); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, username); + try (ResultSet rs = statement.executeQuery()) { + return rs.next(); + } + } + } + + public User create(User user, ServletContext context) throws SQLException, ClassNotFoundException { + String sql = "INSERT INTO users (full_name, username, email, password_hash, avatar_color, bio) VALUES (?, ?, ?, ?, ?, ?)"; + try (Connection connection = DatabaseConnection.getConnection(context); + PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + statement.setString(1, user.getFullName()); + statement.setString(2, user.getUsername()); + statement.setString(3, user.getEmail()); + statement.setString(4, user.getPasswordHash()); + statement.setString(5, user.getAvatarColor()); + statement.setString(6, user.getBio()); + statement.executeUpdate(); + try (ResultSet keys = statement.getGeneratedKeys()) { + if (keys.next()) { + user.setId(keys.getInt(1)); + } + } + return user; + } + } + + public User findByEmail(String email, ServletContext context) throws SQLException, ClassNotFoundException { + String sql = "SELECT * FROM users WHERE email = ? LIMIT 1"; + try (Connection connection = DatabaseConnection.getConnection(context); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, email); + try (ResultSet rs = statement.executeQuery()) { + return rs.next() ? mapUser(rs) : null; + } + } + } + + public User findByEmailOrUsername(String value, ServletContext context) throws SQLException, ClassNotFoundException { + String sql = "SELECT * FROM users WHERE email = ? OR username = ? LIMIT 1"; + try (Connection connection = DatabaseConnection.getConnection(context); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, value); + statement.setString(2, value); + try (ResultSet rs = statement.executeQuery()) { + return rs.next() ? mapUser(rs) : null; + } + } + } + + public List findOtherUsers(int currentUserId, ServletContext context) throws SQLException, ClassNotFoundException { + String sql = "SELECT * FROM users WHERE id <> ? ORDER BY full_name ASC"; + List users = new ArrayList(); + try (Connection connection = DatabaseConnection.getConnection(context); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, currentUserId); + try (ResultSet rs = statement.executeQuery()) { + while (rs.next()) { + users.add(mapUser(rs)); + } + } + } + return users; + } + + public void updatePassword(int userId, String passwordHash, ServletContext context) throws SQLException, ClassNotFoundException { + String sql = "UPDATE users SET password_hash = ? WHERE id = ?"; + try (Connection connection = DatabaseConnection.getConnection(context); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, passwordHash); + statement.setInt(2, userId); + statement.executeUpdate(); + } + } + + private User mapUser(ResultSet rs) throws SQLException { + User user = new User(); + user.setId(rs.getInt("id")); + user.setFullName(rs.getString("full_name")); + user.setUsername(rs.getString("username")); + user.setEmail(rs.getString("email")); + user.setPasswordHash(rs.getString("password_hash")); + user.setAvatarColor(rs.getString("avatar_color")); + user.setBio(rs.getString("bio")); + user.setCreatedAt(rs.getTimestamp("created_at")); + user.setUpdatedAt(rs.getTimestamp("updated_at")); + return user; + } +} diff --git a/RJLResaka/src/com/rjlresaka/dao/package-info.java b/RJLResaka/src/com/rjlresaka/dao/package-info.java new file mode 100644 index 0000000..3c1e41c --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/dao/package-info.java @@ -0,0 +1 @@ +package com.rjlresaka.dao; diff --git a/RJLResaka/src/com/rjlresaka/filter/AuthFilter.java b/RJLResaka/src/com/rjlresaka/filter/AuthFilter.java new file mode 100644 index 0000000..95802fe --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/filter/AuthFilter.java @@ -0,0 +1,43 @@ +package com.rjlresaka.filter; + +import java.io.IOException; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.annotation.WebFilter; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +@WebFilter(urlPatterns = { "/app/*", "/logout" }) +public class AuthFilter implements Filter { + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + // No init params required. + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + HttpSession session = httpRequest.getSession(false); + boolean authenticated = session != null && session.getAttribute("authUser") != null; + + if (!authenticated) { + httpResponse.sendRedirect(httpRequest.getContextPath() + "/login"); + return; + } + chain.doFilter(request, response); + } + + @Override + public void destroy() { + // Nothing to destroy. + } +} diff --git a/RJLResaka/src/com/rjlresaka/filter/CharacterEncodingFilter.java b/RJLResaka/src/com/rjlresaka/filter/CharacterEncodingFilter.java new file mode 100644 index 0000000..5bfe64e --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/filter/CharacterEncodingFilter.java @@ -0,0 +1,33 @@ +package com.rjlresaka.filter; + +import java.io.IOException; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.annotation.WebFilter; + +@WebFilter("/*") +public class CharacterEncodingFilter implements Filter { + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + // No init params required. + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + request.setCharacterEncoding("UTF-8"); + response.setCharacterEncoding("UTF-8"); + chain.doFilter(request, response); + } + + @Override + public void destroy() { + // Nothing to destroy. + } +} diff --git a/RJLResaka/src/com/rjlresaka/filter/package-info.java b/RJLResaka/src/com/rjlresaka/filter/package-info.java new file mode 100644 index 0000000..4e0c8a6 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/filter/package-info.java @@ -0,0 +1 @@ +package com.rjlresaka.filter; diff --git a/RJLResaka/src/com/rjlresaka/model/User.java b/RJLResaka/src/com/rjlresaka/model/User.java new file mode 100644 index 0000000..9e04c43 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/model/User.java @@ -0,0 +1,107 @@ +package com.rjlresaka.model; + +import java.io.Serializable; +import java.sql.Timestamp; + +public class User implements Serializable { + private static final long serialVersionUID = 1L; + + private int id; + private String fullName; + private String username; + private String email; + private String passwordHash; + private String avatarColor; + private String bio; + private Timestamp createdAt; + private Timestamp updatedAt; + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public String getAvatarColor() { + return avatarColor; + } + + public void setAvatarColor(String avatarColor) { + this.avatarColor = avatarColor; + } + + public String getBio() { + return bio; + } + + public void setBio(String bio) { + this.bio = bio; + } + + public Timestamp getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Timestamp createdAt) { + this.createdAt = createdAt; + } + + public Timestamp getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Timestamp updatedAt) { + this.updatedAt = updatedAt; + } + + public String getInitials() { + if (fullName == null || fullName.trim().isEmpty()) { + return "RR"; + } + String[] parts = fullName.trim().split("\\s+"); + StringBuilder initials = new StringBuilder(); + for (String part : parts) { + if (!part.isEmpty()) { + initials.append(Character.toUpperCase(part.charAt(0))); + } + if (initials.length() == 2) { + break; + } + } + return initials.length() == 0 ? "RR" : initials.toString(); + } +} diff --git a/RJLResaka/src/com/rjlresaka/model/package-info.java b/RJLResaka/src/com/rjlresaka/model/package-info.java new file mode 100644 index 0000000..b7f9cfb --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/model/package-info.java @@ -0,0 +1 @@ +package com.rjlresaka.model; diff --git a/RJLResaka/src/com/rjlresaka/servlet/DashboardServlet.java b/RJLResaka/src/com/rjlresaka/servlet/DashboardServlet.java new file mode 100644 index 0000000..a357925 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/DashboardServlet.java @@ -0,0 +1,36 @@ +package com.rjlresaka.servlet; + +import java.io.IOException; +import java.util.List; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import com.rjlresaka.dao.UserDAO; +import com.rjlresaka.model.User; + +@WebServlet("/app/dashboard") +public class DashboardServlet extends HttpServlet { + private static final long serialVersionUID = 1L; + private final UserDAO userDAO = new UserDAO(); + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + HttpSession session = request.getSession(false); + User authUser = (User) session.getAttribute("authUser"); + try { + List users = userDAO.findOtherUsers(authUser.getId(), getServletContext()); + request.setAttribute("users", users); + request.getRequestDispatcher("/WEB-INF/views/dashboard.jsp").forward(request, response); + } catch (Exception exception) { + request.setAttribute("error", "Impossible de charger les utilisateurs pour le moment."); + request.setAttribute("debugMessage", exception.getMessage()); + request.getRequestDispatcher("/WEB-INF/views/dashboard.jsp").forward(request, response); + } + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/ForgotPasswordServlet.java b/RJLResaka/src/com/rjlresaka/servlet/ForgotPasswordServlet.java new file mode 100644 index 0000000..38eb9ea --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/ForgotPasswordServlet.java @@ -0,0 +1,62 @@ +package com.rjlresaka.servlet; + +import java.io.IOException; +import java.sql.Timestamp; +import java.time.LocalDateTime; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.rjlresaka.dao.PasswordResetDAO; +import com.rjlresaka.dao.UserDAO; +import com.rjlresaka.model.User; +import com.rjlresaka.util.TokenUtil; + +@WebServlet("/forgot-password") +public class ForgotPasswordServlet extends HttpServlet { + private static final long serialVersionUID = 1L; + private final UserDAO userDAO = new UserDAO(); + private final PasswordResetDAO passwordResetDAO = new PasswordResetDAO(); + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + request.getRequestDispatcher("/WEB-INF/views/auth/forgot-password.jsp").forward(request, response); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + String email = request.getParameter("email"); + if (email == null || email.trim().isEmpty()) { + request.setAttribute("error", "Entrez votre adresse email."); + request.getRequestDispatcher("/WEB-INF/views/auth/forgot-password.jsp").forward(request, response); + return; + } + + try { + User user = userDAO.findByEmail(email.trim().toLowerCase(), getServletContext()); + if (user == null) { + request.setAttribute("error", "Aucun compte trouvé avec cet email."); + request.getRequestDispatcher("/WEB-INF/views/auth/forgot-password.jsp").forward(request, response); + return; + } + + String token = TokenUtil.randomToken(40); + Timestamp expiresAt = Timestamp.valueOf(LocalDateTime.now().plusMinutes(30)); + passwordResetDAO.createToken(user.getId(), token, expiresAt, getServletContext()); + + request.setAttribute("success", "Lien de réinitialisation généré. Branchez ensuite JavaMail pour l'envoyer par email."); + request.setAttribute("generatedToken", token); + request.setAttribute("resetLink", request.getContextPath() + "/reset-password?token=" + token); + request.getRequestDispatcher("/WEB-INF/views/auth/forgot-password.jsp").forward(request, response); + } catch (Exception exception) { + request.setAttribute("error", "Réinitialisation impossible pour le moment."); + request.setAttribute("debugMessage", exception.getMessage()); + request.getRequestDispatcher("/WEB-INF/views/auth/forgot-password.jsp").forward(request, response); + } + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/HomeServlet.java b/RJLResaka/src/com/rjlresaka/servlet/HomeServlet.java new file mode 100644 index 0000000..a3848d7 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/HomeServlet.java @@ -0,0 +1,26 @@ +package com.rjlresaka.servlet; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +@WebServlet("/home") +public class HomeServlet extends HttpServlet { + private static final long serialVersionUID = 1L; + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + HttpSession session = request.getSession(false); + if (session != null && session.getAttribute("authUser") != null) { + response.sendRedirect(request.getContextPath() + "/app/dashboard"); + return; + } + request.getRequestDispatcher("/WEB-INF/views/home.jsp").forward(request, response); + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/LoginServlet.java b/RJLResaka/src/com/rjlresaka/servlet/LoginServlet.java new file mode 100644 index 0000000..21062a0 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/LoginServlet.java @@ -0,0 +1,56 @@ +package com.rjlresaka.servlet; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import com.rjlresaka.dao.UserDAO; +import com.rjlresaka.model.User; +import com.rjlresaka.util.PasswordUtil; + +@WebServlet("/login") +public class LoginServlet extends HttpServlet { + private static final long serialVersionUID = 1L; + private final UserDAO userDAO = new UserDAO(); + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + request.getRequestDispatcher("/WEB-INF/views/auth/login.jsp").forward(request, response); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + String identity = request.getParameter("identity"); + String password = request.getParameter("password"); + + if (identity == null || identity.trim().isEmpty() || password == null || password.isEmpty()) { + request.setAttribute("error", "Veuillez remplir votre email/username et votre mot de passe."); + request.getRequestDispatcher("/WEB-INF/views/auth/login.jsp").forward(request, response); + return; + } + + try { + User user = userDAO.findByEmailOrUsername(identity.trim(), getServletContext()); + if (user == null || !PasswordUtil.verify(password, user.getPasswordHash())) { + request.setAttribute("error", "Identifiants invalides."); + request.getRequestDispatcher("/WEB-INF/views/auth/login.jsp").forward(request, response); + return; + } + + HttpSession session = request.getSession(); + session.setAttribute("authUser", user); + response.sendRedirect(request.getContextPath() + "/app/dashboard"); + } catch (Exception exception) { + request.setAttribute("error", "Connexion impossible pour le moment. Vérifiez votre base MySQL."); + request.setAttribute("debugMessage", exception.getMessage()); + request.getRequestDispatcher("/WEB-INF/views/auth/login.jsp").forward(request, response); + } + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/LogoutServlet.java b/RJLResaka/src/com/rjlresaka/servlet/LogoutServlet.java new file mode 100644 index 0000000..6014651 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/LogoutServlet.java @@ -0,0 +1,25 @@ +package com.rjlresaka.servlet; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +@WebServlet("/logout") +public class LogoutServlet extends HttpServlet { + private static final long serialVersionUID = 1L; + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + } + response.sendRedirect(request.getContextPath() + "/login"); + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/RegisterServlet.java b/RJLResaka/src/com/rjlresaka/servlet/RegisterServlet.java new file mode 100644 index 0000000..9eac484 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/RegisterServlet.java @@ -0,0 +1,94 @@ +package com.rjlresaka.servlet; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import com.rjlresaka.dao.UserDAO; +import com.rjlresaka.model.User; +import com.rjlresaka.util.PasswordUtil; + +@WebServlet("/register") +public class RegisterServlet extends HttpServlet { + private static final long serialVersionUID = 1L; + private final UserDAO userDAO = new UserDAO(); + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + String fullName = trim(request.getParameter("fullName")); + String username = trim(request.getParameter("username")); + String email = trim(request.getParameter("email")); + String password = request.getParameter("password"); + String confirmPassword = request.getParameter("confirmPassword"); + + if (fullName.isEmpty() || username.isEmpty() || email.isEmpty() || password == null || password.isEmpty()) { + request.setAttribute("error", "Tous les champs sont obligatoires."); + request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response); + return; + } + + if (password.length() < 6) { + request.setAttribute("error", "Le mot de passe doit contenir au moins 6 caractères."); + request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response); + return; + } + + if (!password.equals(confirmPassword)) { + request.setAttribute("error", "La confirmation du mot de passe ne correspond pas."); + request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response); + return; + } + + try { + if (userDAO.emailExists(email, getServletContext())) { + request.setAttribute("error", "Cet email existe déjà."); + request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response); + return; + } + if (userDAO.usernameExists(username, getServletContext())) { + request.setAttribute("error", "Ce nom d'utilisateur existe déjà."); + request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response); + return; + } + + User user = new User(); + user.setFullName(fullName); + user.setUsername(username); + user.setEmail(email.toLowerCase()); + user.setPasswordHash(PasswordUtil.hash(password)); + user.setAvatarColor(pickColor(fullName)); + user.setBio("Nouveau membre RJLResaka"); + userDAO.create(user, getServletContext()); + + HttpSession session = request.getSession(); + session.setAttribute("authUser", user); + response.sendRedirect(request.getContextPath() + "/app/dashboard"); + } catch (Exception exception) { + request.setAttribute("error", "Inscription impossible pour le moment."); + request.setAttribute("debugMessage", exception.getMessage()); + request.getRequestDispatcher("/WEB-INF/views/auth/register.jsp").forward(request, response); + } + } + + private String trim(String value) { + return value == null ? "" : value.trim(); + } + + private String pickColor(String seed) { + String[] colors = { "#0ea5e9", "#2563eb", "#06b6d4", "#14b8a6", "#f97316", "#ec4899" }; + int index = Math.abs(seed.hashCode()) % colors.length; + return colors[index]; + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/ResetPasswordServlet.java b/RJLResaka/src/com/rjlresaka/servlet/ResetPasswordServlet.java new file mode 100644 index 0000000..99ff707 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/ResetPasswordServlet.java @@ -0,0 +1,74 @@ +package com.rjlresaka.servlet; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.rjlresaka.dao.PasswordResetDAO; +import com.rjlresaka.dao.UserDAO; +import com.rjlresaka.util.PasswordUtil; + +@WebServlet("/reset-password") +public class ResetPasswordServlet extends HttpServlet { + private static final long serialVersionUID = 1L; + private final PasswordResetDAO passwordResetDAO = new PasswordResetDAO(); + private final UserDAO userDAO = new UserDAO(); + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + request.getRequestDispatcher("/WEB-INF/views/auth/reset-password.jsp").forward(request, response); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + String token = request.getParameter("token"); + String password = request.getParameter("password"); + String confirmPassword = request.getParameter("confirmPassword"); + + if (token == null || token.trim().isEmpty()) { + request.setAttribute("error", "Le token de réinitialisation est manquant."); + request.getRequestDispatcher("/WEB-INF/views/auth/reset-password.jsp").forward(request, response); + return; + } + + if (password == null || password.length() < 6) { + request.setAttribute("error", "Le nouveau mot de passe doit contenir au moins 6 caractères."); + request.setAttribute("token", token); + request.getRequestDispatcher("/WEB-INF/views/auth/reset-password.jsp").forward(request, response); + return; + } + + if (!password.equals(confirmPassword)) { + request.setAttribute("error", "La confirmation du mot de passe ne correspond pas."); + request.setAttribute("token", token); + request.getRequestDispatcher("/WEB-INF/views/auth/reset-password.jsp").forward(request, response); + return; + } + + try { + Integer userId = passwordResetDAO.findValidUserIdByToken(token.trim(), getServletContext()); + if (userId == null) { + request.setAttribute("error", "Le lien est invalide ou expiré."); + request.setAttribute("token", token); + request.getRequestDispatcher("/WEB-INF/views/auth/reset-password.jsp").forward(request, response); + return; + } + + userDAO.updatePassword(userId.intValue(), PasswordUtil.hash(password), getServletContext()); + passwordResetDAO.markAsUsed(token.trim(), getServletContext()); + request.setAttribute("success", "Mot de passe mis à jour. Vous pouvez vous connecter."); + request.getRequestDispatcher("/WEB-INF/views/auth/login.jsp").forward(request, response); + } catch (Exception exception) { + request.setAttribute("error", "Impossible de mettre à jour le mot de passe."); + request.setAttribute("debugMessage", exception.getMessage()); + request.setAttribute("token", token); + request.getRequestDispatcher("/WEB-INF/views/auth/reset-password.jsp").forward(request, response); + } + } +} diff --git a/RJLResaka/src/com/rjlresaka/servlet/package-info.java b/RJLResaka/src/com/rjlresaka/servlet/package-info.java new file mode 100644 index 0000000..609cfef --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/servlet/package-info.java @@ -0,0 +1 @@ +package com.rjlresaka.servlet; diff --git a/RJLResaka/src/com/rjlresaka/util/DatabaseConnection.java b/RJLResaka/src/com/rjlresaka/util/DatabaseConnection.java new file mode 100644 index 0000000..ac755c4 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/util/DatabaseConnection.java @@ -0,0 +1,23 @@ +package com.rjlresaka.util; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +import javax.servlet.ServletContext; + +public final class DatabaseConnection { + + private DatabaseConnection() { + } + + public static Connection getConnection(ServletContext context) throws SQLException, ClassNotFoundException { + String driver = context.getInitParameter("db.driver"); + String url = context.getInitParameter("db.url"); + String user = context.getInitParameter("db.user"); + String password = context.getInitParameter("db.password"); + + Class.forName(driver); + return DriverManager.getConnection(url, user, password); + } +} diff --git a/RJLResaka/src/com/rjlresaka/util/PasswordUtil.java b/RJLResaka/src/com/rjlresaka/util/PasswordUtil.java new file mode 100644 index 0000000..2f2ebae --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/util/PasswordUtil.java @@ -0,0 +1,20 @@ +package com.rjlresaka.util; + +import org.mindrot.jbcrypt.BCrypt; + +public final class PasswordUtil { + + private PasswordUtil() { + } + + public static String hash(String plainPassword) { + return BCrypt.hashpw(plainPassword, BCrypt.gensalt(10)); + } + + public static boolean verify(String plainPassword, String hash) { + if (plainPassword == null || hash == null || hash.isEmpty()) { + return false; + } + return BCrypt.checkpw(plainPassword, hash); + } +} diff --git a/RJLResaka/src/com/rjlresaka/util/TokenUtil.java b/RJLResaka/src/com/rjlresaka/util/TokenUtil.java new file mode 100644 index 0000000..0925570 --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/util/TokenUtil.java @@ -0,0 +1,19 @@ +package com.rjlresaka.util; + +import java.security.SecureRandom; + +public final class TokenUtil { + private static final String ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private TokenUtil() { + } + + public static String randomToken(int length) { + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(ALPHABET.charAt(RANDOM.nextInt(ALPHABET.length()))); + } + return sb.toString(); + } +} diff --git a/RJLResaka/src/com/rjlresaka/util/package-info.java b/RJLResaka/src/com/rjlresaka/util/package-info.java new file mode 100644 index 0000000..8dbd1bd --- /dev/null +++ b/RJLResaka/src/com/rjlresaka/util/package-info.java @@ -0,0 +1 @@ +package com.rjlresaka.util; diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e8987a..1943ba3 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000..d9ea692 Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index a251b5f..dc0a6c2 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988..26549ce 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f0989c..61d7f80 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 8c38f3f..54ec2a6 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,33 @@ from django.contrib import admin -# Register your models here. +from .models import Conversation, Message, ResakaProfile + + +@admin.register(ResakaProfile) +class ResakaProfileAdmin(admin.ModelAdmin): + list_display = ("display_name", "handle", "status_text", "created_at") + search_fields = ("display_name", "handle", "status_text") + + +class MessageInline(admin.TabularInline): + model = Message + extra = 0 + readonly_fields = ("created_at",) + + +@admin.register(Conversation) +class ConversationAdmin(admin.ModelAdmin): + list_display = ("subject", "starter", "recipient", "updated_at") + search_fields = ("subject", "starter__display_name", "recipient__display_name") + inlines = [MessageInline] + + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + list_display = ("conversation", "author", "short_body", "reaction", "is_read", "created_at") + list_filter = ("is_read", "reaction", "created_at") + search_fields = ("body", "author__display_name") + + @staticmethod + def short_body(obj): + return obj.body[:60] diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..b8204b9 --- /dev/null +++ b/core/forms.py @@ -0,0 +1,101 @@ +from django import forms +from django.utils.text import slugify + +from .models import Message, ResakaProfile + + +AVATAR_CHOICES = [ + ("#22D3EE", "Lagoon"), + ("#FF7A59", "Sunset"), + ("#9EF3D5", "Mint"), + ("#F7C948", "Gold"), +] + +REACTION_CHOICES = [ + ("❤️", "Love"), + ("🔥", "Fire"), + ("😂", "Laugh"), + ("👏", "Clap"), +] + + +class ProfileForm(forms.ModelForm): + class Meta: + model = ResakaProfile + fields = ["display_name", "handle", "status_text", "avatar_color"] + widgets = { + "display_name": forms.TextInput(attrs={"placeholder": "Ex. Rija", "class": "form-control"}), + "handle": forms.TextInput(attrs={"placeholder": "ex. rija_resaka", "class": "form-control"}), + "status_text": forms.TextInput(attrs={"placeholder": "Disponible pour discuter ce soir", "class": "form-control"}), + "avatar_color": forms.Select(attrs={"class": "form-select"}, choices=AVATAR_CHOICES), + } + labels = { + "display_name": "Nom affiché", + "handle": "Pseudo", + "status_text": "Statut", + "avatar_color": "Couleur avatar", + } + + def clean_handle(self): + handle = slugify(self.cleaned_data["handle"]) + if not handle: + raise forms.ValidationError("Choisissez un pseudo valide.") + if ResakaProfile.objects.filter(handle=handle).exists(): + raise forms.ValidationError("Ce pseudo existe déjà.") + return handle + + +class ConversationStartForm(forms.Form): + recipient = forms.ModelChoiceField( + queryset=ResakaProfile.objects.none(), + label="Destinataire", + widget=forms.Select(attrs={"class": "form-select"}), + ) + body = forms.CharField( + label="Premier message", + max_length=1000, + widget=forms.Textarea( + attrs={ + "rows": 3, + "placeholder": "Écris ton premier message privé…", + "class": "form-control", + } + ), + ) + + def __init__(self, *args, active_profile=None, **kwargs): + super().__init__(*args, **kwargs) + queryset = ResakaProfile.objects.all().order_by("display_name") + if active_profile: + queryset = queryset.exclude(pk=active_profile.pk) + self.fields["recipient"].queryset = queryset + self.active_profile = active_profile + + def clean_recipient(self): + recipient = self.cleaned_data["recipient"] + if self.active_profile and recipient.pk == self.active_profile.pk: + raise forms.ValidationError("Choisissez un autre profil pour démarrer une discussion.") + return recipient + + +class MessageForm(forms.ModelForm): + class Meta: + model = Message + fields = ["body"] + widgets = { + "body": forms.Textarea( + attrs={ + "rows": 3, + "placeholder": "Tapez un message privé ou ajoutez des emojis…", + "class": "form-control js-message-body", + } + ) + } + labels = {"body": "Message"} + + +class ReactionForm(forms.Form): + reaction = forms.ChoiceField( + choices=REACTION_CHOICES, + widget=forms.HiddenInput(), + ) diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..ef67096 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,66 @@ +# Generated by Django 5.2.7 on 2026-04-05 20:10 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Conversation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subject', models.CharField(blank=True, max_length=120)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['-updated_at', '-created_at'], + }, + ), + migrations.CreateModel( + name='ResakaProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('display_name', models.CharField(max_length=80)), + ('handle', models.SlugField(max_length=80, unique=True)), + ('status_text', models.CharField(blank=True, max_length=140)), + ('avatar_color', models.CharField(default='#22D3EE', max_length=7)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['display_name'], + }, + ), + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('body', models.TextField()), + ('reaction', models.CharField(blank=True, max_length=8)), + ('is_read', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='core.conversation')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='core.resakaprofile')), + ], + options={ + 'ordering': ['created_at'], + }, + ), + migrations.AddField( + model_name='conversation', + name='recipient', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_conversations', to='core.resakaprofile'), + ), + migrations.AddField( + model_name='conversation', + name='starter', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='started_conversations', to='core.resakaprofile'), + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..bada779 Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 71a8362..600635e 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,70 @@ from django.db import models +from django.urls import reverse -# Create your models here. + +class ResakaProfile(models.Model): + display_name = models.CharField(max_length=80) + handle = models.SlugField(max_length=80, unique=True) + status_text = models.CharField(max_length=140, blank=True) + avatar_color = models.CharField(max_length=7, default="#22D3EE") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["display_name"] + + def __str__(self): + return f"{self.display_name} (@{self.handle})" + + +class Conversation(models.Model): + starter = models.ForeignKey( + ResakaProfile, + on_delete=models.CASCADE, + related_name="started_conversations", + ) + recipient = models.ForeignKey( + ResakaProfile, + on_delete=models.CASCADE, + related_name="received_conversations", + ) + subject = models.CharField(max_length=120, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at", "-created_at"] + + def __str__(self): + return self.subject or f"{self.starter.display_name} → {self.recipient.display_name}" + + def counterpart_for(self, profile): + if profile and self.starter_id == profile.id: + return self.recipient + return self.starter + + def get_absolute_url(self): + return reverse("conversation_detail", args=[self.pk]) + + +class Message(models.Model): + conversation = models.ForeignKey( + Conversation, + on_delete=models.CASCADE, + related_name="messages", + ) + author = models.ForeignKey( + ResakaProfile, + on_delete=models.CASCADE, + related_name="messages", + ) + body = models.TextField() + reaction = models.CharField(max_length=8, blank=True) + is_read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at"] + + def __str__(self): + preview = (self.body[:30] + "…") if len(self.body) > 30 else self.body + return f"{self.author.display_name}: {preview}" diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..5d4986f 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,25 +1,57 @@ +{% load static %} - - + - {% block title %}Knowledge Base{% endblock %} - {% if project_description %} - - - - {% endif %} - {% if project_image_url %} - - - {% endif %} - {% load static %} + + {% block title %}{{ page_title|default:project_name|default:"RJL Resaka" }}{% endblock %} + + + + + + + {% block head %}{% endblock %} + +
+ - - {% block content %}{% endblock %} +
+ {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + {% block content %}{% endblock %} +
+
+ + + + {% block scripts %}{% endblock %} - diff --git a/core/templates/core/chat_detail.html b/core/templates/core/chat_detail.html new file mode 100644 index 0000000..3049508 --- /dev/null +++ b/core/templates/core/chat_detail.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+ + +
+
+
+ Conversation active +

{{ counterpart.display_name }}

+

Messages privés, suivi de lecture et interface responsive.

+
+ notifications lues lors de l'ouverture +
+ +
+ {% for message in messages_list %} +
+
+
+
+ {{ message.author.display_name }} + {{ message.created_at|date:"H:i" }} + {% if message.author_id == active_profile.id and message.is_read %}Lu{% endif %} +
+

{{ message.body|linebreaksbr }}

+ {% if message.reaction %}
{{ message.reaction }}
{% endif %} +
+ +
+ {% for value, label in reaction_choices %} +
+ {% csrf_token %} + + +
+ {% endfor %} +
+
+
+ {% empty %} +
+
✉️
+

Commencez la conversation

+

Envoyez le premier message depuis le bloc ci-dessous.

+
+ {% endfor %} +
+ +
+
+ {% csrf_token %} + + {{ message_form.body }} + {% if message_form.body.errors %}
{{ message_form.body.errors|striptags }}
{% endif %} +
+ Emojis : + + + + +
+
+ + Retour à la liste +
+
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..b363928 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,193 @@ {% extends "base.html" %} -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} - {% block content %} -
-
-

Analyzing your requirements and generating your app…

-
- Loading… +
+
+
+
+
+ Messagerie privée • design inspiré des apps sociales +

Discutez en privé, avec style, notifications et emojis.

+

+ RJL Resaka transforme votre sujet d'examen en première expérience utilisable : créez un profil, + lancez une discussion privée, suivez les messages non lus et réagissez avec des emojis. +

+ +
+
+
+ {{ stats.profiles }} + profils créés +
+
+
+
+ {{ stats.conversations }} + discussions +
+
+
+
+ {{ stats.messages }} + messages envoyés +
+
+
+
+
+
+
+
+
+ +
+
+
Salut 👋 on peut discuter ce soir ?
+
Oui, je t'envoie le brief du projet RJL Resaka.
+
Top, j'adore le style Messenger + réactions ❤️
+
+ +
+
+
-

AppWizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} - — UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) -
-{% endblock %} \ No newline at end of file + + +
+
+
+ Premier MVP slice +

Créer un profil, démarrer un chat, suivre les non-lus.

+

Une vraie boucle de valeur : entrée, confirmation, liste des discussions et détail conversationnel.

+
+ +
+
+
+
+

1. Votre identité

+

Le MVP permet de simuler des profils pour tester une messagerie privée sans flux d'inscription complet.

+
+ + {% if profiles %} +
+ + +
+ {% endif %} + +
+ {% csrf_token %} + {% for field in profile_form %} +
+ + {{ field }} + {% if field.errors %}
{{ field.errors|striptags }}
{% endif %} +
+ {% endfor %} + +
+
+
+ +
+
+
+
+

2. Démarrer une discussion privée

+

{% if active_profile %}Vous envoyez le premier message en tant que {{ active_profile.display_name }}.{% else %}Créez d'abord un profil pour activer le chat.{% endif %}

+
+ {% if active_profile %} +
Connecté comme @{{ active_profile.handle }}
+ {% endif %} +
+ +
+ {% csrf_token %} +
+
+ + {{ conversation_form.recipient }} + {% if conversation_form.recipient.errors %}
{{ conversation_form.recipient.errors|striptags }}
{% endif %} +
+
+ + {{ conversation_form.body }} + {% if conversation_form.body.errors %}
{{ conversation_form.body.errors|striptags }}
{% endif %} +
+
+
+ Ajouts rapides : + + + + +
+ +
+
+ +
+
+
+

3. Discussions récentes

+

Liste responsive avec aperçu du dernier message et badge de notifications non lues.

+
+ {% if active_profile %} + Affichage filtré pour @{{ active_profile.handle }} + {% endif %} +
+ + {% if conversations %} + + {% else %} +
+
💬
+

Aucune discussion pour l'instant

+

Créez un profil puis envoyez un premier message privé pour voir la liste s'animer ici.

+
+ {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/core/urls.py b/core/urls.py index 6299e3d..a5feb88 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,11 @@ from django.urls import path -from .views import home +from .views import conversation_detail, create_profile, home, react_message, start_conversation urlpatterns = [ path("", home, name="home"), + path("profiles/create/", create_profile, name="create_profile"), + path("conversations/start/", start_conversation, name="start_conversation"), + path("conversations//", conversation_detail, name="conversation_detail"), + path("messages//react/", react_message, name="react_message"), ] diff --git a/core/views.py b/core/views.py index c9aed12..f86baf3 100644 --- a/core/views.py +++ b/core/views.py @@ -2,24 +2,263 @@ import os import platform from django import get_version as django_version -from django.shortcuts import render +from django.contrib import messages +from django.db.models import Count, Prefetch, Q +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils import timezone +from .forms import ConversationStartForm, MessageForm, ProfileForm, REACTION_CHOICES, ReactionForm +from .models import Conversation, Message, ResakaProfile + + +DEFAULT_META_DESCRIPTION = ( + "RJL Resaka est une messagerie privée moderne pour lancer des conversations, " + "envoyer des messages et suivre les notifications de lecture." +) + + +def _profile_from_request(request): + profiles = ResakaProfile.objects.order_by("display_name") + profile = None + requested_id = request.GET.get("profile") or request.session.get("active_profile_id") + + if requested_id: + try: + profile = profiles.get(pk=requested_id) + except (ResakaProfile.DoesNotExist, ValueError, TypeError): + profile = None + + if not profile and profiles.exists(): + profile = profiles.first() + + if profile: + request.session["active_profile_id"] = profile.pk + + return profile, profiles + + +def _profile_query_suffix(profile): + return f"?profile={profile.pk}" if profile else "" + + +def _conversation_queryset(active_profile): + base = Conversation.objects.select_related("starter", "recipient").prefetch_related( + Prefetch( + "messages", + queryset=Message.objects.select_related("author").order_by("created_at"), + ) + ) + if active_profile: + base = base.filter(Q(starter=active_profile) | Q(recipient=active_profile)).annotate( + unread_count=Count( + "messages", + filter=Q(messages__is_read=False) & ~Q(messages__author=active_profile), + ) + ) + else: + base = base.none() + return base.order_by("-updated_at") + def home(request): - """Render the landing screen with loader and environment details.""" host_name = request.get_host().lower() agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" now = timezone.now() + active_profile, profiles = _profile_from_request(request) + conversations = list(_conversation_queryset(active_profile)) + + for conversation in conversations: + message_list = list(conversation.messages.all()) + conversation.last_message = message_list[-1] if message_list else None + conversation.counterpart = conversation.counterpart_for(active_profile) + + stats = { + "profiles": profiles.count(), + "conversations": len(conversations), + "messages": Message.objects.count(), + } context = { - "project_name": "New Style", + "project_name": "RJL Resaka", "agent_brand": agent_brand, "django_version": django_version(), "python_version": platform.python_version(), "current_time": now, "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), + "project_description": os.getenv("PROJECT_DESCRIPTION", DEFAULT_META_DESCRIPTION), "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + "page_title": "RJL Resaka | Messagerie privée moderne", + "page_description": DEFAULT_META_DESCRIPTION, + "active_profile": active_profile, + "profiles": profiles, + "profile_form": ProfileForm(), + "conversation_form": ConversationStartForm(active_profile=active_profile), + "conversations": conversations, + "stats": stats, + "profile_query": _profile_query_suffix(active_profile), } return render(request, "core/index.html", context) + + +def create_profile(request): + if request.method != "POST": + return redirect("home") + + form = ProfileForm(request.POST) + if form.is_valid(): + profile = form.save() + request.session["active_profile_id"] = profile.pk + messages.success(request, f"Profil {profile.display_name} créé. Vous pouvez maintenant lancer une discussion.") + return redirect(f"{reverse('home')}?profile={profile.pk}") + + active_profile, profiles = _profile_from_request(request) + conversations = list(_conversation_queryset(active_profile)) + for conversation in conversations: + message_list = list(conversation.messages.all()) + conversation.last_message = message_list[-1] if message_list else None + conversation.counterpart = conversation.counterpart_for(active_profile) + + context = { + "project_name": "RJL Resaka", + "page_title": "Créer un profil | RJL Resaka", + "page_description": DEFAULT_META_DESCRIPTION, + "active_profile": active_profile, + "profiles": profiles, + "profile_form": form, + "conversation_form": ConversationStartForm(active_profile=active_profile), + "conversations": conversations, + "stats": { + "profiles": profiles.count(), + "conversations": len(conversations), + "messages": Message.objects.count(), + }, + "profile_query": _profile_query_suffix(active_profile), + "current_time": timezone.now(), + "django_version": django_version(), + "python_version": platform.python_version(), + } + return render(request, "core/index.html", context, status=400) + + +def start_conversation(request): + if request.method != "POST": + return redirect("home") + + active_profile, _profiles = _profile_from_request(request) + if not active_profile: + messages.error(request, "Créez ou sélectionnez d'abord un profil pour discuter.") + return redirect("home") + + form = ConversationStartForm(request.POST, active_profile=active_profile) + if not form.is_valid(): + messages.error(request, "Impossible de lancer la discussion. Vérifiez les champs du formulaire.") + return redirect(f"{reverse('home')}?profile={active_profile.pk}") + + recipient = form.cleaned_data["recipient"] + body = form.cleaned_data["body"] + conversation = ( + Conversation.objects.filter( + (Q(starter=active_profile) & Q(recipient=recipient)) + | (Q(starter=recipient) & Q(recipient=active_profile)) + ) + .select_related("starter", "recipient") + .first() + ) + created = False + if not conversation: + conversation = Conversation.objects.create( + starter=active_profile, + recipient=recipient, + subject=f"Discussion privée · {active_profile.display_name} & {recipient.display_name}", + ) + created = True + + Message.objects.create( + conversation=conversation, + author=active_profile, + body=body, + is_read=False, + ) + conversation.save(update_fields=["updated_at"]) + + if created: + messages.success(request, f"Nouvelle conversation lancée avec {recipient.display_name}.") + else: + messages.success(request, f"Message envoyé à {recipient.display_name}.") + return redirect(f"{conversation.get_absolute_url()}?profile={active_profile.pk}") + + +def conversation_detail(request, pk): + active_profile, profiles = _profile_from_request(request) + conversation = get_object_or_404( + Conversation.objects.select_related("starter", "recipient").prefetch_related( + Prefetch("messages", queryset=Message.objects.select_related("author").order_by("created_at")) + ), + pk=pk, + ) + + if not active_profile or active_profile.pk not in {conversation.starter_id, conversation.recipient_id}: + if active_profile: + messages.error(request, "Cette conversation n'appartient pas au profil sélectionné.") + return redirect(f"{reverse('home')}?profile={active_profile.pk}") + messages.error(request, "Sélectionnez un profil pour ouvrir une conversation.") + return redirect("home") + + if request.method == "POST": + form = MessageForm(request.POST) + if form.is_valid(): + message = form.save(commit=False) + message.conversation = conversation + message.author = active_profile + message.is_read = False + message.save() + conversation.save(update_fields=["updated_at"]) + messages.success(request, "Message envoyé.") + return redirect(f"{conversation.get_absolute_url()}?profile={active_profile.pk}") + else: + form = MessageForm() + + Message.objects.filter(conversation=conversation).exclude(author=active_profile).filter(is_read=False).update(is_read=True) + + message_list = list(conversation.messages.all()) + counterpart = conversation.counterpart_for(active_profile) + reaction_forms = {message.pk: ReactionForm() for message in message_list} + + context = { + "project_name": "RJL Resaka", + "page_title": f"Chat avec {counterpart.display_name} | RJL Resaka", + "page_description": f"Conversation privée entre {active_profile.display_name} et {counterpart.display_name} sur RJL Resaka.", + "active_profile": active_profile, + "profiles": profiles, + "conversation": conversation, + "counterpart": counterpart, + "message_form": form, + "messages_list": message_list, + "reaction_forms": reaction_forms, + "reaction_choices": REACTION_CHOICES, + "profile_query": _profile_query_suffix(active_profile), + } + return render(request, "core/chat_detail.html", context) + + +def react_message(request, pk): + if request.method != "POST": + raise Http404 + + active_profile, _profiles = _profile_from_request(request) + message = get_object_or_404(Message.objects.select_related("conversation", "author"), pk=pk) + conversation = message.conversation + if not active_profile or active_profile.pk not in {conversation.starter_id, conversation.recipient_id}: + messages.error(request, "Sélectionnez un profil valide pour réagir au message.") + return redirect("home") + + form = ReactionForm(request.POST) + if form.is_valid(): + message.reaction = form.cleaned_data["reaction"] + message.save(update_fields=["reaction"]) + messages.success(request, "Réaction envoyée.") + else: + messages.error(request, "Réaction invalide.") + return redirect(f"{conversation.get_absolute_url()}?profile={active_profile.pk}") diff --git a/static/css/custom.css b/static/css/custom.css index 925f6ed..95e80a3 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1,4 +1,700 @@ -/* Custom styles for the application */ -body { - font-family: system-ui, -apple-system, sans-serif; +/* RJL Resaka custom theme */ +:root { + --bg: #08111f; + --bg-soft: #0e1a2c; + --surface: rgba(10, 21, 38, 0.82); + --surface-strong: #12233a; + --surface-border: rgba(255, 255, 255, 0.08); + --text: #ecf6ff; + --muted: #a7bed3; + --primary: #22d3ee; + --primary-dark: #0ea5b7; + --secondary: #ff7a59; + --accent: #9ef3d5; + --gold: #f7c948; + --danger: #ff7a59; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.34); + --radius-xl: 28px; + --radius-lg: 22px; + --radius-md: 16px; + --space-section: clamp(4rem, 7vw, 6rem); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body.resaka-body { + margin: 0; + font-family: "Inter", system-ui, sans-serif; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(34, 211, 238, 0.18), transparent 30%), + radial-gradient(circle at 85% 10%, rgba(255, 122, 89, 0.12), transparent 26%), + linear-gradient(180deg, #09111f 0%, #0d1c32 45%, #09111f 100%); + min-height: 100vh; +} + +body.resaka-body::before, +body.resaka-body::after { + content: ""; + position: fixed; + inset: auto; + border-radius: 999px; + pointer-events: none; + z-index: 0; + filter: blur(20px); +} + +body.resaka-body::before { + width: 280px; + height: 280px; + top: 7rem; + right: -6rem; + background: rgba(34, 211, 238, 0.18); +} + +body.resaka-body::after { + width: 200px; + height: 200px; + left: -4rem; + bottom: 15%; + background: rgba(255, 122, 89, 0.12); +} + +.resaka-shell, +.site-header, +main { + position: relative; + z-index: 1; +} + +.container-xl { + max-width: 1240px; +} + +.navbar { + background: rgba(7, 16, 29, 0.48); + border: 1px solid var(--surface-border); + border-radius: 999px; + padding: 0.85rem 1.15rem; + backdrop-filter: blur(20px); + box-shadow: 0 10px 35px rgba(0, 0, 0, 0.2); +} + +.brand-mark { + font-family: "Manrope", sans-serif; + font-weight: 800; + letter-spacing: -0.04em; + font-size: 1.3rem; +} + +.brand-mark span, +.nav-link:hover, +.nav-link:focus { + color: var(--primary) !important; +} + +.nav-link { + color: rgba(236, 246, 255, 0.82) !important; + font-weight: 600; +} + +.profile-pill, +.presence-chip, +.panel-note { + display: inline-flex; + align-items: center; + gap: 0.55rem; + border-radius: 999px; + padding: 0.6rem 0.95rem; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + color: var(--text); + font-size: 0.94rem; +} + +.flash-stack { + margin-top: 0.35rem; +} + +.flash-stack .alert { + background: rgba(18, 35, 58, 0.92); + color: var(--text); + border-left: 4px solid var(--primary); + border-radius: 18px; +} + +.hero-section, +.workflow-section, +.detail-section { + padding: var(--space-section) 0; +} + +.hero-copy { + padding-right: 0.5rem; +} + +.eyebrow { + display: inline-flex; + padding: 0.45rem 0.8rem; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.74rem; + font-weight: 700; + color: var(--accent); + background: rgba(158, 243, 213, 0.08); + border: 1px solid rgba(158, 243, 213, 0.14); + margin-bottom: 1.15rem; +} + +.display-title, +.section-heading h2, +.thread-header h2, +.profile-spotlight h1 { + font-family: "Manrope", sans-serif; + letter-spacing: -0.04em; +} + +.display-title { + font-size: clamp(2.9rem, 6vw, 5.2rem); + line-height: 0.98; + margin: 0 0 1.4rem; +} + +.lead-copy, +.section-heading p, +.panel-header p, +.muted-copy, +.preview-footer, +.conversation-copy p { + color: var(--muted); + font-size: 1.04rem; +} + +.hero-actions, +.helper-row { + align-items: center; +} + +.metric-card, +.card-panel, +.info-card, +.preview-window { + background: var(--surface); + border: 1px solid var(--surface-border); + backdrop-filter: blur(22px); + box-shadow: var(--shadow); +} + +.metric-card { + border-radius: 22px; + padding: 1.15rem 1rem; +} + +.metric-card strong { + display: block; + font-size: 1.65rem; + font-family: "Manrope", sans-serif; +} + +.metric-card span { + color: var(--muted); +} + +.card-panel, +.info-card { + border-radius: var(--radius-xl); + padding: 1.6rem; +} + +.chat-preview-panel { + position: relative; + overflow: hidden; +} + +.chat-preview-panel::after { + content: ""; + position: absolute; + inset: auto -2.5rem -3rem auto; + width: 180px; + height: 180px; + border-radius: 32px; + transform: rotate(24deg); + background: linear-gradient(135deg, rgba(34, 211, 238, 0.4), rgba(158, 243, 213, 0.08)); +} + +.preview-window { + border-radius: 28px; + padding: 1.2rem; + position: relative; + z-index: 1; +} + +.preview-topbar { + display: flex; + gap: 0.45rem; + margin-bottom: 1.15rem; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.25); +} + +.preview-thread { + display: grid; + gap: 0.95rem; +} + +.bubble, +.message-bubble { + max-width: 88%; + border-radius: 22px; + padding: 0.95rem 1rem; + line-height: 1.55; +} + +.bubble-incoming, +.message-row:not(.is-own) .message-bubble { + background: rgba(255, 255, 255, 0.07); + border-top-left-radius: 8px; +} + +.bubble-outgoing, +.message-row.is-own .message-bubble { + margin-left: auto; + background: linear-gradient(135deg, rgba(34, 211, 238, 0.95), rgba(14, 165, 183, 0.92)); + color: #062130; + border-top-right-radius: 8px; +} + +.bubble-accent { + border: 1px solid rgba(255, 122, 89, 0.4); +} + +.preview-footer { + margin-top: 1rem; + display: flex; + align-items: center; + gap: 0.6rem; +} + +.presence-dot { + width: 9px; + height: 9px; + border-radius: 999px; + background: var(--accent); + box-shadow: 0 0 0 6px rgba(158, 243, 213, 0.12); + flex-shrink: 0; +} + +.section-heading { + max-width: 720px; + margin-bottom: 2rem; +} + +.section-heading h2 { + font-size: clamp(2rem, 4vw, 3.2rem); + margin-bottom: 0.8rem; +} + +.panel-header { + margin-bottom: 1.2rem; +} + +.panel-header h3, +.info-card h3 { + font-family: "Manrope", sans-serif; + font-size: 1.35rem; + margin-bottom: 0.45rem; +} + +.form-control, +.form-select { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + color: var(--text); + padding: 0.86rem 1rem; +} + +.form-control::placeholder { + color: rgba(167, 190, 211, 0.8); +} + +.form-control:focus, +.form-select:focus { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(34, 211, 238, 0.6); + box-shadow: 0 0 0 0.25rem rgba(34, 211, 238, 0.15); + color: var(--text); +} + +.form-select { + background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); + background-position: calc(100% - 18px) calc(1.25rem), calc(100% - 12px) calc(1.25rem); + background-size: 6px 6px, 6px 6px; + background-repeat: no-repeat; +} + +.form-select option { + color: #0a1526; +} + +.form-label, +.helper-label { + color: rgba(236, 246, 255, 0.92); + font-weight: 600; +} + +.form-error { + color: #ffd1c7; + font-size: 0.92rem; + margin-top: 0.45rem; +} + +.btn { + border-radius: 999px; + font-weight: 700; + padding: 0.88rem 1.35rem; + transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease; +} + +.btn:hover, +.btn:focus { + transform: translateY(-1px); +} + +.btn-brand { + color: #072033; + border: 0; + background: linear-gradient(135deg, var(--primary), #66e6f0); + box-shadow: 0 18px 36px rgba(34, 211, 238, 0.22); +} + +.btn-brand:hover, +.btn-brand:focus { + color: #072033; + background: linear-gradient(135deg, #6ce8f4, var(--primary)); +} + +.btn-ghost { + color: var(--text); + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); +} + +.btn-ghost:hover, +.btn-ghost:focus { + color: var(--text); + background: rgba(255, 255, 255, 0.08); +} + +.emoji-chip, +.reaction-chip { + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + color: var(--text); + border-radius: 999px; + padding: 0.5rem 0.8rem; + font-size: 0.92rem; + text-decoration: none; +} + +.emoji-chip:hover, +.reaction-chip:hover, +.emoji-chip:focus, +.reaction-chip:focus { + background: rgba(34, 211, 238, 0.14); + color: var(--text); +} + +.reaction-chip.static { + display: inline-flex; +} + +.conversation-list { + display: grid; + gap: 1rem; +} + +.conversation-card { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 1rem; + padding: 1.1rem; + border-radius: 22px; + text-decoration: none; + color: inherit; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease; +} + +.conversation-card:hover, +.conversation-card:focus { + transform: translateY(-2px); + border-color: rgba(34, 211, 238, 0.28); + background: rgba(255, 255, 255, 0.06); + color: inherit; +} + +.avatar-orb { + width: 54px; + height: 54px; + border-radius: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #072033; + font-family: "Manrope", sans-serif; + font-weight: 800; + font-size: 1.3rem; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.24); +} + +.avatar-xl { + width: 86px; + height: 86px; + border-radius: 28px; + font-size: 2rem; +} + +.conversation-head { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: baseline; +} + +.conversation-head h4, +.profile-spotlight h1 { + margin: 0; + font-size: 1.2rem; +} + +.conversation-copy strong, +.conversation-copy span, +.conversation-head span, +.meta-list span, +.meta-list strong, +.message-meta span { + display: inline-block; +} + +.conversation-copy strong { + margin-right: 0.35rem; +} + +.conversation-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.85rem; +} + +.unread-badge, +.read-chip, +.reaction-pill { + border-radius: 999px; + padding: 0.3rem 0.7rem; + font-weight: 700; +} + +.unread-badge { + background: rgba(255, 122, 89, 0.16); + color: #ffd8cf; + border: 1px solid rgba(255, 122, 89, 0.24); +} + +.open-link, +.back-link { + color: var(--primary); + font-weight: 700; + text-decoration: none; +} + +.empty-state { + text-align: center; + padding: 2.2rem 1.4rem; + border-radius: 24px; + background: rgba(255, 255, 255, 0.04); + border: 1px dashed rgba(255, 255, 255, 0.12); +} + +.empty-state.compact { + padding: 1.8rem 1rem; +} + +.empty-illustration { + font-size: 2.5rem; + margin-bottom: 0.8rem; +} + +.detail-grid { + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + gap: 1.35rem; +} + +.profile-spotlight { + text-align: left; +} + +.profile-spotlight p { + color: var(--muted); +} + +.meta-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.9rem; +} + +.meta-list li { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.meta-list span { + color: var(--muted); +} + +.thread-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; + margin-bottom: 1.2rem; +} + +.thread-header h2 { + font-size: 2rem; + margin: 0.3rem 0 0.4rem; +} + +.message-thread { + display: grid; + gap: 1rem; + max-height: 62vh; + overflow-y: auto; + padding-right: 0.35rem; +} + +.message-row { + display: flex; +} + +.message-row.is-own { + justify-content: flex-end; +} + +.message-bubble-wrap { + display: grid; + gap: 0.5rem; + max-width: min(100%, 720px); +} + +.message-meta { + display: flex; + align-items: center; + gap: 0.65rem; + margin-bottom: 0.55rem; + font-size: 0.88rem; +} + +.message-bubble p { + margin: 0; +} + +.message-row.is-own .message-meta, +.message-row.is-own .message-bubble p { + color: #072033; +} + +.read-chip { + background: rgba(7, 32, 51, 0.14); + color: #072033; +} + +.reaction-pill { + margin-top: 0.8rem; + background: rgba(255, 255, 255, 0.14); + display: inline-flex; +} + +.reaction-actions { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.reaction-actions form { + margin: 0; +} + +.composer-panel { + margin-top: 1.35rem; + padding-top: 1.25rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.reaction-preview-row { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.site-header .navbar-toggler:focus { + box-shadow: 0 0 0 0.2rem rgba(34, 211, 238, 0.22); +} + +@media (max-width: 1199.98px) { + .detail-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 767.98px) { + .hero-section, + .workflow-section, + .detail-section { + padding: 2.8rem 0; + } + + .navbar { + border-radius: 24px; + } + + .display-title { + font-size: clamp(2.4rem, 12vw, 3.4rem); + } + + .conversation-card { + grid-template-columns: 1fr; + } + + .conversation-meta { + align-items: flex-start; + } + + .thread-header { + flex-direction: column; + } + + .bubble, + .message-bubble { + max-width: 100%; + } }