| Nom |
- Email |
+ Identifiant / Email |
Rôle |
Statut |
Dernière connexion |
@@ -193,7 +219,25 @@ export default function UsersAdmin() {
{user.name}
- {user.email} |
+
+
+ {user.username && (
+
+
+ {user.username}
+
+ )}
+ {user.email && (
+
+
+ {user.email}
+
+ )}
+ {!user.username && !user.email && (
+ —
+ )}
+
+ |
{ROLE_ICONS[user.role]}
@@ -218,10 +262,20 @@ export default function UsersAdmin() {
|
-
@@ -241,23 +295,65 @@ export default function UsersAdmin() {
{editingUser ? "Modifier l'utilisateur" : "Nouvel utilisateur"}
- {editingUser ? "Modifiez les informations de l'utilisateur" : "Créez un nouveau compte utilisateur"}
+ {editingUser
+ ? "Modifiez les informations de l'utilisateur"
+ : "Créez un nouveau compte. L'identifiant ou l'e-mail servira à la connexion."}
+ {/* Nom complet */}
-
- setForm((f) => ({ ...f, name: e.target.value }))} />
+
+ setForm((f) => ({ ...f, name: e.target.value }))}
+ />
+ {/* Identifiant */}
-
- setForm((f) => ({ ...f, email: e.target.value }))} />
+
+ setForm((f) => ({ ...f, username: e.target.value }))}
+ />
+
+ Permet la connexion sans e-mail. Ex : adminItinova
+
+ {/* Email */}
-
- setForm((f) => ({ ...f, password: e.target.value }))} />
+
+ setForm((f) => ({ ...f, email: e.target.value }))}
+ />
+ {/* Mot de passe */}
+
+
+ setForm((f) => ({ ...f, password: e.target.value }))}
+ />
+
+ {/* Rôle */}
+ {/* Statut (modification uniquement) */}
{editingUser && (
- setForm((f) => ({ ...f, isActive: v }))} />
+ setForm((f) => ({ ...f, isActive: v }))}
+ />
{form.isActive ? "Actif" : "Inactif"}
@@ -283,9 +383,16 @@ export default function UsersAdmin() {
- setShowDialog(false)}>Annuler
-
- {(createMutation.isPending || updateMutation.isPending) && }
+ setShowDialog(false)}>
+ Annuler
+
+
+ {(createMutation.isPending || updateMutation.isPending) && (
+
+ )}
{editingUser ? "Enregistrer" : "Créer"}
@@ -297,11 +404,19 @@ export default function UsersAdmin() {
Supprimer l'utilisateur
- Cette action est irréversible. L'utilisateur ne pourra plus se connecter.
+
+ Cette action est irréversible. L'utilisateur ne pourra plus se connecter.
+
- setDeleteId(null)}>Annuler
- deleteId && deleteMutation.mutate({ id: deleteId })} disabled={deleteMutation.isPending}>
+ setDeleteId(null)}>
+ Annuler
+
+ deleteId && deleteMutation.mutate({ id: deleteId })}
+ disabled={deleteMutation.isPending}
+ >
{deleteMutation.isPending && }
Supprimer
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index 5ec111e..f9c8d18 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -31,7 +31,8 @@ export type InsertUser = typeof users.$inferInsert;
export const localUsers = mysqlTable("local_users", {
id: int("id").autoincrement().primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
- email: varchar("email", { length: 320 }).notNull().unique(),
+ username: varchar("username", { length: 128 }).unique(),
+ email: varchar("email", { length: 320 }),
passwordHash: varchar("passwordHash", { length: 255 }).notNull(),
role: mysqlEnum("role", ["admin", "user", "readonly"]).default("user").notNull(),
isActive: boolean("isActive").default(true).notNull(),
@@ -115,3 +116,22 @@ export const importLogs = mysqlTable("import_logs", {
export type ImportLog = typeof importLogs.$inferSelect;
export type InsertImportLog = typeof importLogs.$inferInsert;
+
+// ─── Boîte à idées ───────────────────────────────────────────────────────────
+
+export const ideas = mysqlTable("ideas", {
+ id: int("id").autoincrement().primaryKey(),
+ userId: int("userId").notNull(),
+ userName: varchar("userName", { length: 255 }).notNull(),
+ titre: varchar("titre", { length: 512 }).notNull(),
+ message: text("message").notNull(),
+ statut: mysqlEnum("statut", ["ouvert", "en_cours", "resolu", "ferme"]).default("ouvert").notNull(),
+ reponseAdmin: text("reponseAdmin"),
+ reponduPar: varchar("reponduPar", { length: 255 }),
+ reponduAt: timestamp("reponduAt"),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type Idea = typeof ideas.$inferSelect;
+export type InsertIdea = typeof ideas.$inferInsert;
diff --git a/server/db.ts b/server/db.ts
index bce1572..f3104e3 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -9,6 +9,8 @@ import {
appSettings,
importLogs,
InsertLocalUser,
+ ideas,
+ InsertIdea,
} from "../drizzle/schema";
import { ENV } from "./_core/env";
@@ -70,6 +72,7 @@ export async function getLocalUsers() {
.select({
id: localUsers.id,
name: localUsers.name,
+ username: localUsers.username,
email: localUsers.email,
role: localUsers.role,
isActive: localUsers.isActive,
@@ -315,3 +318,45 @@ export async function getImportStats() {
totalNewRows,
};
}
+
+// ─── Boîte à idées ────────────────────────────────────────────────────────────
+
+export async function createIdea(data: InsertIdea) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ await db.insert(ideas).values(data);
+}
+
+export async function getAllIdeas() {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(ideas).orderBy(desc(ideas.createdAt));
+}
+
+export async function getIdeasByUser(userId: number) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(ideas).where(eq(ideas.userId, userId)).orderBy(desc(ideas.createdAt));
+}
+
+export async function repondreIdea(
+ id: number,
+ reponseAdmin: string,
+ reponduPar: string,
+ statut: "ouvert" | "en_cours" | "resolu" | "ferme"
+) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ await db.update(ideas).set({
+ reponseAdmin,
+ reponduPar,
+ reponduAt: new Date(),
+ statut,
+ }).where(eq(ideas.id, id));
+}
+
+export async function updateIdeaStatut(id: number, statut: "ouvert" | "en_cours" | "resolu" | "ferme") {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ await db.update(ideas).set({ statut }).where(eq(ideas.id, id));
+}
diff --git a/server/localAuth.ts b/server/localAuth.ts
index d348db7..b00e10d 100644
--- a/server/localAuth.ts
+++ b/server/localAuth.ts
@@ -7,7 +7,7 @@ import { ENV } from "./_core/env";
const SALT_ROUNDS = 12;
const JWT_EXPIRY = "7d";
-const LOCAL_AUTH_COOKIE = "veille_local_auth";
+export const LOCAL_AUTH_COOKIE = "veille_local_auth";
export async function hashPassword(password: string): Promise {
return bcrypt.hash(password, SALT_ROUNDS);
@@ -37,24 +37,30 @@ export async function verifyLocalToken(token: string): Promise<{ userId: number;
}
}
-export async function loginLocalUser(email: string, password: string) {
+/**
+ * Connexion par username OU email (insensible à la casse pour l'email).
+ * Le champ `identifier` peut être un nom d'utilisateur libre ou une adresse e-mail.
+ */
+export async function loginLocalUser(identifier: string, password: string) {
const db = await getDb();
if (!db) throw new Error("Base de données indisponible");
- // Recherche par e-mail (insensible à la casse) OU par identifiant exact
- const identifier = email.trim();
- const users = await db
+ const id = identifier.trim();
+
+ // Cherche d'abord par username exact, puis par email (insensible à la casse)
+ const results = await db
.select()
.from(localUsers)
.where(
or(
- eq(localUsers.email, identifier.toLowerCase()),
- eq(localUsers.email, identifier)
+ eq(localUsers.username, id),
+ eq(localUsers.email, id.toLowerCase()),
+ eq(localUsers.email, id)
)
)
.limit(1);
- const user = users[0];
+ const user = results[0];
if (!user || !user.isActive) {
throw new Error("Identifiants incorrects ou compte désactivé");
}
@@ -62,21 +68,29 @@ export async function loginLocalUser(email: string, password: string) {
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) throw new Error("Identifiants incorrects ou compte désactivé");
- // Mise à jour lastSignedIn
await db
.update(localUsers)
.set({ lastSignedIn: new Date() })
.where(eq(localUsers.id, user.id));
const token = await generateLocalToken(user.id, user.role);
- return { token, user: { id: user.id, name: user.name, email: user.email, role: user.role } };
+ return {
+ token,
+ user: {
+ id: user.id,
+ name: user.name,
+ username: user.username ?? null,
+ email: user.email ?? null,
+ role: user.role,
+ },
+ };
}
export async function getLocalUserById(id: number) {
const db = await getDb();
if (!db) return null;
- const users = await db.select().from(localUsers).where(eq(localUsers.id, id)).limit(1);
- return users[0] ?? null;
+ const results = await db.select().from(localUsers).where(eq(localUsers.id, id)).limit(1);
+ return results[0] ?? null;
}
export async function ensureAdminExists() {
@@ -93,11 +107,12 @@ export async function ensureAdminExists() {
const hash = await hashPassword("Admin@Itinova2024!");
await db.insert(localUsers).values({
name: "Administrateur",
+ username: "admin",
email: "admin@itinova.fr",
passwordHash: hash,
role: "admin",
isActive: true,
});
- console.log("[LocalAuth] Compte admin par défaut créé : admin@itinova.fr / Admin@Itinova2024!");
+ console.log("[LocalAuth] Compte admin par défaut créé : admin / Admin@Itinova2024!");
}
}
diff --git a/server/routers.ts b/server/routers.ts
index 23e4351..2a9598c 100644
--- a/server/routers.ts
+++ b/server/routers.ts
@@ -17,6 +17,11 @@ import {
createLocalUser,
updateLocalUser,
deleteLocalUser,
+ createIdea,
+ getAllIdeas,
+ getIdeasByUser,
+ repondreIdea,
+ updateIdeaStatut,
} from "./db";
import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer";
import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth";
@@ -190,7 +195,8 @@ export const appRouter = router({
.input(
z.object({
name: z.string().min(2).max(255),
- email: z.string().email(),
+ username: z.string().min(2).max(128).optional(),
+ email: z.string().email().optional(),
password: z.string().min(8),
role: z.enum(["admin", "user", "readonly"]).default("user"),
})
@@ -199,7 +205,8 @@ export const appRouter = router({
const passwordHash = await hashPassword(input.password);
await createLocalUser({
name: input.name,
- email: input.email.toLowerCase(),
+ username: input.username ?? null,
+ email: input.email ? input.email.toLowerCase() : null,
passwordHash,
role: input.role,
isActive: true,
@@ -212,6 +219,7 @@ export const appRouter = router({
z.object({
id: z.number().int().positive(),
name: z.string().min(2).max(255).optional(),
+ username: z.string().min(2).max(128).optional(),
email: z.string().email().optional(),
password: z.string().min(8).optional(),
role: z.enum(["admin", "user", "readonly"]).optional(),
@@ -238,6 +246,67 @@ export const appRouter = router({
return { success: true };
}),
}),
+
+ // ─── Boîte à idées ───────────────────────────────────────────────────────────
+ ideas: router({
+ // Créer une nouvelle idée / question
+ create: protectedProcedure
+ .input(
+ z.object({
+ titre: z.string().min(3).max(512),
+ message: z.string().min(10),
+ })
+ )
+ .mutation(async ({ input, ctx }) => {
+ await createIdea({
+ userId: ctx.user.id,
+ userName: ctx.user.name ?? ctx.user.email ?? "Utilisateur",
+ titre: input.titre,
+ message: input.message,
+ });
+ return { success: true };
+ }),
+
+ // Lister toutes les idées (admin) ou les siennes (user)
+ list: protectedProcedure.query(async ({ ctx }) => {
+ if (ctx.user.role === "admin") {
+ return getAllIdeas();
+ }
+ return getIdeasByUser(ctx.user.id);
+ }),
+
+ // Répondre à une idée (admin uniquement)
+ repondre: adminProcedure
+ .input(
+ z.object({
+ id: z.number().int().positive(),
+ reponseAdmin: z.string().min(1),
+ statut: z.enum(["ouvert", "en_cours", "resolu", "ferme"]),
+ })
+ )
+ .mutation(async ({ input, ctx }) => {
+ await repondreIdea(
+ input.id,
+ input.reponseAdmin,
+ ctx.user.name ?? ctx.user.email ?? "Admin",
+ input.statut
+ );
+ return { success: true };
+ }),
+
+ // Changer le statut (admin uniquement)
+ updateStatut: adminProcedure
+ .input(
+ z.object({
+ id: z.number().int().positive(),
+ statut: z.enum(["ouvert", "en_cours", "resolu", "ferme"]),
+ })
+ )
+ .mutation(async ({ input }) => {
+ await updateIdeaStatut(input.id, input.statut);
+ return { success: true };
+ }),
+ }),
});
export type AppRouter = typeof appRouter;
|