mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 15:08:13 +02:00
17 files changed
+1798
-101
No files matched your search
@@ -14,6 +14,18 @@ run = ["npm", "run", "start"]
|
|||||||
localPort = 5000
|
localPort = 5000
|
||||||
externalPort = 80
|
externalPort = 80
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 35739
|
||||||
|
externalPort = 3002
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 40537
|
||||||
|
externalPort = 3000
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 43245
|
||||||
|
externalPort = 3001
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
PORT = "5000"
|
PORT = "5000"
|
||||||
|
|
||||||
|
|||||||
+61
-11
@@ -1,9 +1,11 @@
|
|||||||
import { Switch, Route } from "wouter";
|
import { Switch, Route, Redirect, useLocation } from "wouter";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { queryClient } from "./lib/queryClient";
|
import { queryClient } from "./lib/queryClient";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider, useQuery } from "@tanstack/react-query";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
|
import Login from "@/pages/login";
|
||||||
import Dashboard from "@/pages/dashboard";
|
import Dashboard from "@/pages/dashboard";
|
||||||
import NewPost from "@/pages/new-post";
|
import NewPost from "@/pages/new-post";
|
||||||
import Calendar from "@/pages/calendar";
|
import Calendar from "@/pages/calendar";
|
||||||
@@ -13,19 +15,67 @@ import AI from "@/pages/ai";
|
|||||||
import Analytics from "@/pages/analytics";
|
import Analytics from "@/pages/analytics";
|
||||||
import History from "@/pages/history";
|
import History from "@/pages/history";
|
||||||
import Settings from "@/pages/settings";
|
import Settings from "@/pages/settings";
|
||||||
|
import SqlAdmin from "@/pages/sql";
|
||||||
|
import UsersAdmin from "@/pages/users-admin";
|
||||||
|
|
||||||
|
function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) {
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
|
||||||
|
const { data: session, isLoading } = useQuery({
|
||||||
|
queryKey: ["/api/auth/session"],
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !session) {
|
||||||
|
setLocation("/login");
|
||||||
|
}
|
||||||
|
}, [isLoading, session, setLocation]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen bg-background">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||||
|
<p className="text-muted-foreground">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminOnly && (session as any).role !== "admin") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen bg-background">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold mb-2">Accès refusé</h1>
|
||||||
|
<p className="text-muted-foreground">Cette page est réservée aux administrateurs.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Component />;
|
||||||
|
}
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" component={Dashboard} />
|
<Route path="/login" component={Login} />
|
||||||
<Route path="/new" component={NewPost} />
|
<Route path="/">{() => <ProtectedRoute component={Dashboard} />}</Route>
|
||||||
<Route path="/calendar" component={Calendar} />
|
<Route path="/new">{() => <ProtectedRoute component={NewPost} />}</Route>
|
||||||
<Route path="/media" component={Media} />
|
<Route path="/calendar">{() => <ProtectedRoute component={Calendar} />}</Route>
|
||||||
<Route path="/pages" component={PagesManagement} />
|
<Route path="/media">{() => <ProtectedRoute component={Media} />}</Route>
|
||||||
<Route path="/ai" component={AI} />
|
<Route path="/pages">{() => <ProtectedRoute component={PagesManagement} />}</Route>
|
||||||
<Route path="/analytics" component={Analytics} />
|
<Route path="/ai">{() => <ProtectedRoute component={AI} />}</Route>
|
||||||
<Route path="/history" component={History} />
|
<Route path="/analytics">{() => <ProtectedRoute component={Analytics} />}</Route>
|
||||||
<Route path="/settings" component={Settings} />
|
<Route path="/history">{() => <ProtectedRoute component={History} />}</Route>
|
||||||
|
<Route path="/settings">{() => <ProtectedRoute component={Settings} adminOnly />}</Route>
|
||||||
|
<Route path="/sql">{() => <ProtectedRoute component={SqlAdmin} adminOnly />}</Route>
|
||||||
|
<Route path="/users">{() => <ProtectedRoute component={UsersAdmin} adminOnly />}</Route>
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,38 @@
|
|||||||
import { Home, PlusCircle, Calendar, Images, Users, Bot, BarChart3, Clock, Settings } from "lucide-react";
|
import { Home, PlusCircle, Calendar, Images, Users, Bot, BarChart3, Clock, Settings, Database, UserCog, LogOut } from "lucide-react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Charger la session utilisateur
|
||||||
|
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
||||||
|
queryKey: ["/api/auth/session"],
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAdmin = session?.role === "admin";
|
||||||
|
|
||||||
|
// Mutation pour déconnexion
|
||||||
|
const logoutMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await apiRequest("POST", "/api/auth/logout", {});
|
||||||
|
return await res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.clear();
|
||||||
|
setLocation("/login");
|
||||||
|
toast({
|
||||||
|
title: "Déconnecté",
|
||||||
|
description: "Vous avez été déconnecté avec succès",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ icon: Home, label: "Tableau de bord", href: "/", badge: null },
|
{ icon: Home, label: "Tableau de bord", href: "/", badge: null },
|
||||||
@@ -94,15 +124,77 @@ export default function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-border">
|
<div className="p-4 border-t border-border space-y-2">
|
||||||
<Link
|
{isAdmin && (
|
||||||
href="/settings"
|
<>
|
||||||
className="flex items-center gap-3 px-4 py-3 rounded-lg text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all"
|
<div className="mb-2">
|
||||||
data-testid="link-settings"
|
<p className="text-xs text-muted-foreground mb-2 px-4 uppercase tracking-wider">Administration</p>
|
||||||
>
|
</div>
|
||||||
<Settings className="w-5 h-5" />
|
<Link
|
||||||
<span>Paramètres</span>
|
href="/users"
|
||||||
</Link>
|
className={`
|
||||||
|
flex items-center gap-3 px-4 py-3 rounded-lg transition-all
|
||||||
|
${location === "/users"
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
data-testid="link-users"
|
||||||
|
>
|
||||||
|
<UserCog className="w-5 h-5" />
|
||||||
|
<span>Utilisateurs</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/sql"
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 px-4 py-3 rounded-lg transition-all
|
||||||
|
${location === "/sql"
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
data-testid="link-sql"
|
||||||
|
>
|
||||||
|
<Database className="w-5 h-5" />
|
||||||
|
<span>SQL</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 px-4 py-3 rounded-lg transition-all
|
||||||
|
${location === "/settings"
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
data-testid="link-settings"
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
<span>Paramètres</span>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start text-muted-foreground hover:text-accent-foreground"
|
||||||
|
onClick={() => logoutMutation.mutate()}
|
||||||
|
disabled={logoutMutation.isPending}
|
||||||
|
data-testid="button-logout"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5 mr-3" />
|
||||||
|
{logoutMutation.isPending ? "Déconnexion..." : "Déconnexion"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session && (
|
||||||
|
<div className="mt-4 px-4 py-3 bg-muted rounded-lg">
|
||||||
|
<p className="text-xs text-muted-foreground">Connecté en tant que</p>
|
||||||
|
<p className="text-sm font-semibold truncate">{session.username}</p>
|
||||||
|
<p className="text-xs text-muted-foreground capitalize">{session.role}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { Lock, User } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showDefaultPassword, setShowDefaultPassword] = useState(false);
|
||||||
|
|
||||||
|
// Vérifier si le mot de passe admin par défaut est toujours actif
|
||||||
|
useEffect(() => {
|
||||||
|
const checkDefaultPassword = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/default-password-status");
|
||||||
|
const data = await response.json();
|
||||||
|
setShowDefaultPassword(data.isDefault === true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking default password status:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkDefaultPassword();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiRequest("POST", "/api/auth/login", { username, password });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Connexion réussie",
|
||||||
|
description: `Bienvenue ${data.username}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rediriger vers le dashboard
|
||||||
|
setLocation("/");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Erreur de connexion",
|
||||||
|
description: error.message || "Nom d'utilisateur ou mot de passe incorrect",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1 text-center">
|
||||||
|
<CardTitle className="text-3xl font-bold">Social Flow</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Connectez-vous à votre compte pour gérer vos publications
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username" data-testid="label-username">
|
||||||
|
Nom d'utilisateur
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="admin"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="input-username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password" data-testid="label-password">
|
||||||
|
Mot de passe
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="input-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
data-testid="button-login"
|
||||||
|
>
|
||||||
|
{isLoading ? "Connexion en cours..." : "Se connecter"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{showDefaultPassword && (
|
||||||
|
<div className="mt-6 text-center text-sm text-muted-foreground">
|
||||||
|
<p>Identifiants par défaut :</p>
|
||||||
|
<p className="font-semibold">admin / admin</p>
|
||||||
|
<p className="text-xs mt-2 text-orange-500">⚠️ Changez ce mot de passe après la première connexion</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud } from "lucide-react";
|
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain } from "lucide-react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import TopBar from "@/components/topbar";
|
import TopBar from "@/components/topbar";
|
||||||
@@ -7,6 +7,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
@@ -18,12 +20,23 @@ export default function Settings() {
|
|||||||
const [cloudName, setCloudName] = useState("");
|
const [cloudName, setCloudName] = useState("");
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [apiSecret, setApiSecret] = useState("");
|
const [apiSecret, setApiSecret] = useState("");
|
||||||
|
const [openrouterApiKey, setOpenrouterApiKey] = useState("");
|
||||||
|
const [openrouterModel, setOpenrouterModel] = useState("anthropic/claude-3.5-sonnet");
|
||||||
|
const [openrouterSystemPrompt, setOpenrouterSystemPrompt] = useState("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: cloudinaryConfig } = useQuery({
|
const { data: cloudinaryConfig } = useQuery({
|
||||||
queryKey: ['/api/cloudinary/config'],
|
queryKey: ['/api/cloudinary/config'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: openrouterConfig } = useQuery({
|
||||||
|
queryKey: ['/api/openrouter/config'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: openrouterModels } = useQuery({
|
||||||
|
queryKey: ['/api/openrouter/models'],
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cloudinaryConfig) {
|
if (cloudinaryConfig) {
|
||||||
setCloudName((cloudinaryConfig as any).cloudName || "");
|
setCloudName((cloudinaryConfig as any).cloudName || "");
|
||||||
@@ -31,6 +44,16 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
}, [cloudinaryConfig]);
|
}, [cloudinaryConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openrouterConfig) {
|
||||||
|
setOpenrouterModel((openrouterConfig as any).model || "anthropic/claude-3.5-sonnet");
|
||||||
|
setOpenrouterSystemPrompt((openrouterConfig as any).systemPrompt || "Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement.");
|
||||||
|
}
|
||||||
|
}, [openrouterConfig]);
|
||||||
|
|
||||||
|
// Vérifier si une config OpenRouter existe déjà
|
||||||
|
const hasExistingConfig = !!openrouterConfig;
|
||||||
|
|
||||||
const saveCloudinaryMutation = useMutation({
|
const saveCloudinaryMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
apiRequest('POST', '/api/cloudinary/config', {
|
apiRequest('POST', '/api/cloudinary/config', {
|
||||||
@@ -55,6 +78,30 @@ export default function Settings() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveOpenrouterMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiRequest('POST', '/api/openrouter/config', {
|
||||||
|
apiKey: openrouterApiKey,
|
||||||
|
model: openrouterModel,
|
||||||
|
systemPrompt: openrouterSystemPrompt,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/openrouter/config'] });
|
||||||
|
toast({
|
||||||
|
title: "Configuration sauvegardée",
|
||||||
|
description: "Vos paramètres OpenRouter ont été enregistrés",
|
||||||
|
});
|
||||||
|
setOpenrouterApiKey(""); // Clear the API key after saving
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Impossible de sauvegarder la configuration OpenRouter",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
@@ -128,26 +175,84 @@ export default function Settings() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Key className="w-5 h-5" />
|
<Brain className="w-5 h-5" />
|
||||||
Clés API
|
Configuration OpenRouter (IA)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configurez vos clés API pour les intégrations
|
Configurez OpenRouter pour la génération de texte avec l'IA
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="openrouterKey">Clé API OpenRouter</Label>
|
<Label htmlFor="openrouterApiKey">Clé API OpenRouter</Label>
|
||||||
<Input
|
<Input
|
||||||
id="openrouterKey"
|
id="openrouterApiKey"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="sk-or-v1-..."
|
value={openrouterApiKey}
|
||||||
data-testid="input-openrouter-key"
|
onChange={(e) => setOpenrouterApiKey(e.target.value)}
|
||||||
|
placeholder={hasExistingConfig ? "••••••••••••••••" : "sk-or-v1-..."}
|
||||||
|
data-testid="input-openrouter-api-key"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Utilisée pour la génération de texte IA
|
{hasExistingConfig ? (
|
||||||
|
<>
|
||||||
|
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span> - Laissez vide pour garder la clé actuelle
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Obtenez votre clé API sur{" "}
|
||||||
|
<a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer" className="underline">
|
||||||
|
openrouter.ai
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="openrouterModel">Modèle d'IA</Label>
|
||||||
|
<Select value={openrouterModel} onValueChange={setOpenrouterModel}>
|
||||||
|
<SelectTrigger id="openrouterModel" data-testid="select-openrouter-model">
|
||||||
|
<SelectValue placeholder="Sélectionner un modèle" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(openrouterModels as any)?.data?.map((model: any) => (
|
||||||
|
<SelectItem key={model.id} value={model.id}>
|
||||||
|
{model.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Choisissez le modèle d'IA pour générer vos publications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="openrouterSystemPrompt">Prompt système</Label>
|
||||||
|
<Textarea
|
||||||
|
id="openrouterSystemPrompt"
|
||||||
|
value={openrouterSystemPrompt}
|
||||||
|
onChange={(e) => setOpenrouterSystemPrompt(e.target.value)}
|
||||||
|
placeholder="Instructions pour l'IA..."
|
||||||
|
rows={6}
|
||||||
|
data-testid="textarea-openrouter-system-prompt"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Définissez les instructions que l'IA doit suivre pour générer vos textes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => saveOpenrouterMutation.mutate()}
|
||||||
|
disabled={
|
||||||
|
saveOpenrouterMutation.isPending ||
|
||||||
|
!openrouterModel ||
|
||||||
|
!openrouterSystemPrompt ||
|
||||||
|
(!hasExistingConfig && !openrouterApiKey)
|
||||||
|
}
|
||||||
|
data-testid="button-save-openrouter"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{saveOpenrouterMutation.isPending ? "Enregistrement..." : "Enregistrer OpenRouter"}
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import Sidebar from "@/components/sidebar";
|
||||||
|
import TopBar from "@/components/topbar";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { Database, Download, Play } from "lucide-react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
|
export default function SqlAdmin() {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [sqlQuery, setSqlQuery] = useState("");
|
||||||
|
const [queryResult, setQueryResult] = useState<any>(null);
|
||||||
|
|
||||||
|
// Charger la liste des tables
|
||||||
|
const { data: tablesData } = useQuery({
|
||||||
|
queryKey: ["/api/sql/tables"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation pour exécuter une requête SQL
|
||||||
|
const executeSqlMutation = useMutation({
|
||||||
|
mutationFn: async (query: string) => {
|
||||||
|
const res = await apiRequest("POST", "/api/sql/execute", { query });
|
||||||
|
return await res.json();
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setQueryResult(data);
|
||||||
|
if (data.success) {
|
||||||
|
toast({
|
||||||
|
title: "Requête exécutée",
|
||||||
|
description: "La requête SQL a été exécutée avec succès",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Erreur SQL",
|
||||||
|
description: error.message || "Erreur lors de l'exécution de la requête",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleExecuteQuery = () => {
|
||||||
|
if (!sqlQuery.trim()) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Veuillez entrer une requête SQL",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
executeSqlMutation.mutate(sqlQuery);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadResult = () => {
|
||||||
|
if (!queryResult?.result) return;
|
||||||
|
|
||||||
|
const json = JSON.stringify(queryResult.result, null, 2);
|
||||||
|
const blob = new Blob([json], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `sql-result-${Date.now()}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertTable = (tableName: string) => {
|
||||||
|
setSqlQuery(`SELECT * FROM ${tableName} LIMIT 10;`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`
|
||||||
|
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||||
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||||
|
`}>
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||||
|
<Database className="w-8 h-8" />
|
||||||
|
Administration SQL
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Exécutez des requêtes SQL et gérez votre base de données
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
{/* Liste des tables */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tables disponibles</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Cliquez pour insérer dans la requête
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(tablesData as any)?.tables?.map((table: any) => (
|
||||||
|
<Button
|
||||||
|
key={table.tablename}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left"
|
||||||
|
onClick={() => insertTable(table.tablename)}
|
||||||
|
data-testid={`button-table-${table.tablename}`}
|
||||||
|
>
|
||||||
|
<Database className="w-4 h-4 mr-2" />
|
||||||
|
{table.tablename}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Éditeur SQL et résultats */}
|
||||||
|
<div className="lg:col-span-3 space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Éditeur SQL</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Entrez votre requête SQL ci-dessous
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
placeholder="SELECT * FROM users LIMIT 10;"
|
||||||
|
value={sqlQuery}
|
||||||
|
onChange={(e) => setSqlQuery(e.target.value)}
|
||||||
|
className="font-mono min-h-[200px]"
|
||||||
|
data-testid="textarea-sql-query"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleExecuteQuery}
|
||||||
|
disabled={executeSqlMutation.isPending}
|
||||||
|
data-testid="button-execute-sql"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
{executeSqlMutation.isPending ? "Exécution..." : "Exécuter"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setSqlQuery("")}
|
||||||
|
data-testid="button-clear-sql"
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Résultats */}
|
||||||
|
{queryResult && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Résultats</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{queryResult.success ? "Requête exécutée avec succès" : "Erreur lors de l'exécution"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{queryResult.success && queryResult.result && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownloadResult}
|
||||||
|
data-testid="button-download-result"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Télécharger JSON
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{queryResult.success ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
{Array.isArray(queryResult.result) && queryResult.result.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{Object.keys(queryResult.result[0]).map((key) => (
|
||||||
|
<TableHead key={key}>{key}</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{queryResult.result.map((row: any, idx: number) => (
|
||||||
|
<TableRow key={idx}>
|
||||||
|
{Object.values(row).map((value: any, cellIdx: number) => (
|
||||||
|
<TableCell key={cellIdx} className="font-mono text-xs">
|
||||||
|
{value === null ? (
|
||||||
|
<span className="text-muted-foreground italic">null</span>
|
||||||
|
) : typeof value === 'object' ? (
|
||||||
|
JSON.stringify(value)
|
||||||
|
) : (
|
||||||
|
String(value)
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<pre className="bg-muted p-4 rounded-lg overflow-auto max-h-96">
|
||||||
|
<code>{JSON.stringify(queryResult.result, null, 2)}</code>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-destructive/10 border border-destructive/20 text-destructive p-4 rounded-lg">
|
||||||
|
<p className="font-semibold">Erreur :</p>
|
||||||
|
<p className="font-mono text-sm mt-2">{queryResult.error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import Sidebar from "@/components/sidebar";
|
||||||
|
import TopBar from "@/components/topbar";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { Users, UserPlus, Pencil, Trash2, Shield, User as UserIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type UserData = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: "admin" | "user";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UsersAdmin() {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [role, setRole] = useState<"admin" | "user">("user");
|
||||||
|
|
||||||
|
// Edit dialog states
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<UserData | null>(null);
|
||||||
|
const [editUsername, setEditUsername] = useState("");
|
||||||
|
const [editPassword, setEditPassword] = useState("");
|
||||||
|
const [editRole, setEditRole] = useState<"admin" | "user">("user");
|
||||||
|
|
||||||
|
// Delete dialog states
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [userToDelete, setUserToDelete] = useState<UserData | null>(null);
|
||||||
|
|
||||||
|
// Charger la liste des utilisateurs
|
||||||
|
const { data: users, isLoading } = useQuery<UserData[]>({
|
||||||
|
queryKey: ["/api/users"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation pour créer un utilisateur
|
||||||
|
const createUserMutation = useMutation({
|
||||||
|
mutationFn: async (userData: { username: string; password: string; role: string }) => {
|
||||||
|
const res = await apiRequest("POST", "/api/users", userData);
|
||||||
|
return await res.json();
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast({
|
||||||
|
title: "Utilisateur créé",
|
||||||
|
description: `L'utilisateur ${data.username} a été créé avec succès`,
|
||||||
|
});
|
||||||
|
setUsername("");
|
||||||
|
setPassword("");
|
||||||
|
setRole("user");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Erreur",
|
||||||
|
description: error.message || "Erreur lors de la création de l'utilisateur",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation pour modifier un utilisateur
|
||||||
|
const updateUserMutation = useMutation({
|
||||||
|
mutationFn: async ({ id, data }: { id: string; data: any }) => {
|
||||||
|
const res = await apiRequest("PATCH", `/api/users/${id}`, data);
|
||||||
|
return await res.json();
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast({
|
||||||
|
title: "Utilisateur modifié",
|
||||||
|
description: `L'utilisateur ${data.username} a été modifié avec succès`,
|
||||||
|
});
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Erreur",
|
||||||
|
description: error.message || "Erreur lors de la modification de l'utilisateur",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation pour supprimer un utilisateur
|
||||||
|
const deleteUserMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const res = await apiRequest("DELETE", `/api/users/${id}`, {});
|
||||||
|
return await res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Utilisateur supprimé",
|
||||||
|
description: "L'utilisateur a été supprimé avec succès",
|
||||||
|
});
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setUserToDelete(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Erreur",
|
||||||
|
description: error.message || "Erreur lors de la suppression de l'utilisateur",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Veuillez remplir tous les champs",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 4) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Le mot de passe doit contenir au moins 4 caractères",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createUserMutation.mutate({ username, password, role });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditClick = (user: UserData) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setEditUsername(user.username);
|
||||||
|
setEditPassword("");
|
||||||
|
setEditRole(user.role);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSubmit = () => {
|
||||||
|
if (!editingUser) return;
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
|
||||||
|
if (editUsername !== editingUser.username) {
|
||||||
|
updateData.username = editUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editPassword) {
|
||||||
|
updateData.password = editPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editRole !== editingUser.role) {
|
||||||
|
updateData.role = editRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length === 0) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Aucune modification détectée",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUserMutation.mutate({ id: editingUser.id, data: updateData });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (user: UserData) => {
|
||||||
|
setUserToDelete(user);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (userToDelete) {
|
||||||
|
deleteUserMutation.mutate(userToDelete.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`
|
||||||
|
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||||
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||||
|
`}>
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||||
|
<Users className="w-8 h-8" />
|
||||||
|
Gestion des utilisateurs
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Créez et gérez les utilisateurs de l'application
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Liste des utilisateurs */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Liste des utilisateurs</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{users?.length || 0} utilisateur(s) enregistré(s)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<p className="text-muted-foreground mt-4">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
) : users && users.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Nom d'utilisateur</TableHead>
|
||||||
|
<TableHead>Rôle</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium flex items-center gap-2">
|
||||||
|
{user.role === "admin" ? (
|
||||||
|
<Shield className="w-4 h-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<UserIcon className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{user.username}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
|
||||||
|
{user.role === "admin" ? "Administrateur" : "Utilisateur"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditClick(user)}
|
||||||
|
data-testid={`button-edit-user-${user.id}`}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteClick(user)}
|
||||||
|
data-testid={`button-delete-user-${user.id}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground">Aucun utilisateur trouvé</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulaire de création */}
|
||||||
|
<div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<UserPlus className="w-5 h-5" />
|
||||||
|
Créer un utilisateur
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ajouter un nouvel utilisateur
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Nom d'utilisateur</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="utilisateur123"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
data-testid="input-new-username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Mot de passe</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
data-testid="input-new-password"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Minimum 4 caractères
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">Rôle</Label>
|
||||||
|
<Select value={role} onValueChange={(value: "admin" | "user") => setRole(value)}>
|
||||||
|
<SelectTrigger id="role" data-testid="select-user-role">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">Utilisateur</SelectItem>
|
||||||
|
<SelectItem value="admin">Administrateur</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={createUserMutation.isPending}
|
||||||
|
data-testid="button-create-user"
|
||||||
|
>
|
||||||
|
{createUserMutation.isPending ? "Création..." : "Créer"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Dialog de modification */}
|
||||||
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Modifiez les informations de l'utilisateur. Laissez le mot de passe vide pour ne pas le changer.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-username">Nom d'utilisateur</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-username"
|
||||||
|
value={editUsername}
|
||||||
|
onChange={(e) => setEditUsername(e.target.value)}
|
||||||
|
data-testid="input-edit-username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-password">Nouveau mot de passe (optionnel)</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Laisser vide pour ne pas changer"
|
||||||
|
value={editPassword}
|
||||||
|
onChange={(e) => setEditPassword(e.target.value)}
|
||||||
|
data-testid="input-edit-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-role">Rôle</Label>
|
||||||
|
<Select value={editRole} onValueChange={(value: "admin" | "user") => setEditRole(value)}>
|
||||||
|
<SelectTrigger id="edit-role" data-testid="select-edit-role">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">Utilisateur</SelectItem>
|
||||||
|
<SelectItem value="admin">Administrateur</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleEditSubmit}
|
||||||
|
disabled={updateUserMutation.isPending}
|
||||||
|
data-testid="button-save-edit"
|
||||||
|
>
|
||||||
|
{updateUserMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Dialog de confirmation de suppression */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Êtes-vous sûr de vouloir supprimer l'utilisateur <strong>{userToDelete?.username}</strong> ?
|
||||||
|
Cette action est irréversible.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
data-testid="button-confirm-delete"
|
||||||
|
>
|
||||||
|
{deleteUserMutation.isPending ? "Suppression..." : "Supprimer"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Script d'initialisation de la base de données Social Flow
|
||||||
|
-- Ce fichier contient les commandes SQL pour créer les tables et l'utilisateur admin par défaut
|
||||||
|
|
||||||
|
-- Créer l'enum pour les rôles utilisateur (si pas déjà créé par drizzle-kit push)
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE user_role AS ENUM ('admin', 'user');
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Créer l'utilisateur admin par défaut (username: admin, password: admin)
|
||||||
|
-- Note: Le hash bcrypt pour "admin" est généré avec un salt de 10
|
||||||
|
INSERT INTO users (id, username, password, role)
|
||||||
|
VALUES (
|
||||||
|
'admin-user-id',
|
||||||
|
'admin',
|
||||||
|
'$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G',
|
||||||
|
'admin'
|
||||||
|
) ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
|
-- Créer un utilisateur demo pour les tests
|
||||||
|
INSERT INTO users (id, username, password, role)
|
||||||
|
VALUES (
|
||||||
|
'demo-user',
|
||||||
|
'demo',
|
||||||
|
'$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G',
|
||||||
|
'user'
|
||||||
|
) ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
|
-- Liste des tables créées automatiquement par Drizzle ORM :
|
||||||
|
-- - users (utilisateurs avec rôles)
|
||||||
|
-- - social_pages (pages Facebook/Instagram connectées)
|
||||||
|
-- - cloudinary_config (configuration Cloudinary par utilisateur)
|
||||||
|
-- - openrouter_config (configuration OpenRouter par utilisateur)
|
||||||
|
-- - media (fichiers médias stockés dans Cloudinary)
|
||||||
|
-- - posts (publications créées)
|
||||||
|
-- - scheduled_posts (publications programmées)
|
||||||
|
-- - ai_generations (historique des générations IA)
|
||||||
|
-- - post_media (relation many-to-many entre posts et media)
|
||||||
Generated
+37
-4
@@ -40,9 +40,11 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cloudinary": "^2.7.0",
|
"cloudinary": "^2.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -3862,6 +3864,15 @@
|
|||||||
"@babel/types": "^7.20.7"
|
"@babel/types": "^7.20.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.5",
|
"version": "1.19.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
||||||
@@ -4308,6 +4319,20 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^8.3.0",
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -6474,6 +6499,15 @@
|
|||||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "8.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
||||||
|
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-cron": {
|
"node_modules/node-cron": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||||
@@ -6484,11 +6518,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-gyp-build": {
|
"node_modules/node-gyp-build": {
|
||||||
"version": "4.8.3",
|
"version": "4.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
"integrity": "sha512-EMS95CMJzdoSKoIiXo8pxKoL8DYxwIZXYlLmgPb8KUv794abpnLK6ynsCAWNliOjREKruYKdzbh76HHYUHX7nw==",
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"node-gyp-build": "bin.js",
|
"node-gyp-build": "bin.js",
|
||||||
"node-gyp-build-optional": "optional.js",
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
|||||||
@@ -42,9 +42,11 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cloudinary": "^2.7.0",
|
"cloudinary": "^2.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -18,13 +18,40 @@ Social Flow is a comprehensive social media automation platform designed to stre
|
|||||||
|
|
||||||
Preferred communication style: Simple, everyday language.
|
Preferred communication style: Simple, everyday language.
|
||||||
|
|
||||||
|
## Authentication & Authorization
|
||||||
|
|
||||||
|
**System**: Passport.js avec stratégie locale et bcrypt pour le hachage des mots de passe
|
||||||
|
|
||||||
|
**Rôles utilisateur**:
|
||||||
|
- `admin` - Accès complet à tous les paramètres, SQL, et gestion des utilisateurs
|
||||||
|
- `user` - Accès aux fonctionnalités de publication uniquement (pas de paramètres)
|
||||||
|
|
||||||
|
**Utilisateur par défaut**:
|
||||||
|
- Username: `admin`
|
||||||
|
- Password: `admin`
|
||||||
|
- Rôle: admin
|
||||||
|
- **Important**: Changez ce mot de passe en production
|
||||||
|
|
||||||
|
**Session Management**:
|
||||||
|
- express-session avec cookies HTTP-only
|
||||||
|
- sameSite: 'lax' pour prévention CSRF
|
||||||
|
- Durée de session: 7 jours
|
||||||
|
- Secret de session via variable d'environnement `SESSION_SECRET`
|
||||||
|
|
||||||
|
**Protection des routes**:
|
||||||
|
- Backend: Middleware `requireAuth` pour routes utilisateur, `requireAdmin` pour routes admin
|
||||||
|
- Frontend: Composant `ProtectedRoute` avec vérification de session et redirection vers /login
|
||||||
|
|
||||||
## System Architecture
|
## System Architecture
|
||||||
|
|
||||||
### Frontend Architecture
|
### Frontend Architecture
|
||||||
|
|
||||||
**Framework**: React with TypeScript using Vite as the build tool
|
**Framework**: React with TypeScript using Vite as the build tool
|
||||||
|
|
||||||
**Routing**: Wouter for lightweight client-side routing
|
**Routing**: Wouter for lightweight client-side routing with protected routes
|
||||||
|
- Public: `/login`
|
||||||
|
- Protected (authenticated): Dashboard, Media, AI, Posts, Calendar, Pages, Analytics, History
|
||||||
|
- Protected (admin only): `/settings`, `/sql`, `/users`
|
||||||
|
|
||||||
**State Management**:
|
**State Management**:
|
||||||
- TanStack Query (React Query) for server state management and caching
|
- TanStack Query (React Query) for server state management and caching
|
||||||
@@ -49,11 +76,16 @@ Preferred communication style: Simple, everyday language.
|
|||||||
**Type Safety**: Full TypeScript implementation across frontend, backend, and shared schema
|
**Type Safety**: Full TypeScript implementation across frontend, backend, and shared schema
|
||||||
|
|
||||||
**API Design**: RESTful endpoints with conventional HTTP methods
|
**API Design**: RESTful endpoints with conventional HTTP methods
|
||||||
- `/api/stats` - Dashboard statistics
|
- `/api/auth/*` - Authentication (login, logout, session)
|
||||||
- `/api/ai/generate` - AI text generation
|
- `/api/users` - User management (admin only)
|
||||||
- `/api/media/*` - Media upload and management
|
- `/api/sql/*` - SQL administration (admin only)
|
||||||
- `/api/pages/*` - Social page management
|
- `/api/stats` - Dashboard statistics (authenticated)
|
||||||
- `/api/posts/*` - Post and scheduling management
|
- `/api/ai/generate` - AI text generation (authenticated)
|
||||||
|
- `/api/media/*` - Media upload and management (authenticated)
|
||||||
|
- `/api/pages/*` - Social page management (authenticated)
|
||||||
|
- `/api/posts/*` - Post and scheduling management (authenticated)
|
||||||
|
- `/api/cloudinary/config` - Cloudinary configuration (admin only)
|
||||||
|
- `/api/openrouter/config` - OpenRouter configuration (admin only)
|
||||||
|
|
||||||
**File Upload**: Multer middleware for handling multipart/form-data with in-memory storage strategy, integrated with Cloudinary for cloud storage
|
**File Upload**: Multer middleware for handling multipart/form-data with in-memory storage strategy, integrated with Cloudinary for cloud storage
|
||||||
|
|
||||||
@@ -79,7 +111,7 @@ Preferred communication style: Simple, everyday language.
|
|||||||
**Schema Design**:
|
**Schema Design**:
|
||||||
|
|
||||||
Core Tables:
|
Core Tables:
|
||||||
- `users` - User authentication and profiles
|
- `users` - User authentication and profiles with role-based access (admin/user)
|
||||||
- `cloudinary_config` - Cloudinary configuration per user (cloud name, API key, API secret)
|
- `cloudinary_config` - Cloudinary configuration per user (cloud name, API key, API secret)
|
||||||
- `social_pages` - Connected Facebook/Instagram pages with access tokens
|
- `social_pages` - Connected Facebook/Instagram pages with access tokens
|
||||||
- `media` - Uploaded media files with Cloudinary public IDs and transformation URLs
|
- `media` - Uploaded media files with Cloudinary public IDs and transformation URLs
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import passport from "passport";
|
||||||
|
import { Strategy as LocalStrategy } from "passport-local";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { storage } from "./storage";
|
||||||
|
import type { User } from "@shared/schema";
|
||||||
|
|
||||||
|
// Configuration de la stratégie locale
|
||||||
|
passport.use(
|
||||||
|
new LocalStrategy(async (username, password, done) => {
|
||||||
|
try {
|
||||||
|
const user = await storage.getUserByUsername(username);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return done(null, false, { message: "Nom d'utilisateur ou mot de passe incorrect" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidPassword = await bcrypt.compare(password, user.password);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return done(null, false, { message: "Nom d'utilisateur ou mot de passe incorrect" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return done(null, user);
|
||||||
|
} catch (error) {
|
||||||
|
return done(error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sérialisation de l'utilisateur pour la session
|
||||||
|
passport.serializeUser((user: Express.User, done) => {
|
||||||
|
done(null, (user as User).id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Désérialisation de l'utilisateur depuis la session
|
||||||
|
passport.deserializeUser(async (id: string, done) => {
|
||||||
|
try {
|
||||||
|
const user = await storage.getUser(id);
|
||||||
|
done(null, user);
|
||||||
|
} catch (error) {
|
||||||
|
done(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default passport;
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import express, { type Request, Response, NextFunction } from "express";
|
import express, { type Request, Response, NextFunction } from "express";
|
||||||
|
import session from "express-session";
|
||||||
|
import passport from "./auth";
|
||||||
import { registerRoutes } from "./routes";
|
import { registerRoutes } from "./routes";
|
||||||
import { setupVite, serveStatic, log } from "./vite";
|
import { setupVite, serveStatic, log } from "./vite";
|
||||||
import { schedulerService } from "./services/scheduler";
|
import { schedulerService } from "./services/scheduler";
|
||||||
@@ -17,6 +19,26 @@ app.use(express.json({
|
|||||||
}));
|
}));
|
||||||
app.use(express.urlencoded({ extended: false }));
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
|
||||||
|
// Configuration des sessions
|
||||||
|
if (!process.env.SESSION_SECRET) {
|
||||||
|
console.warn('⚠️ SESSION_SECRET non défini. Utilisation d\'une clé par défaut (NON SÉCURISÉ en production)');
|
||||||
|
}
|
||||||
|
app.use(session({
|
||||||
|
secret: process.env.SESSION_SECRET || 'your-secret-key-change-me',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Initialisation de Passport
|
||||||
|
app.use(passport.initialize());
|
||||||
|
app.use(passport.session());
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const path = req.path;
|
const path = req.path;
|
||||||
|
|||||||
+399
-32
@@ -1,20 +1,293 @@
|
|||||||
import type { Express } from "express";
|
import type { Express, Request, Response, NextFunction } from "express";
|
||||||
import { createServer, type Server } from "http";
|
import { createServer, type Server } from "http";
|
||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import passport from "./auth";
|
||||||
|
import { z } from "zod";
|
||||||
import { openRouterService } from "./services/openrouter";
|
import { openRouterService } from "./services/openrouter";
|
||||||
import { cloudinaryService } from "./services/cloudinary";
|
import { cloudinaryService } from "./services/cloudinary";
|
||||||
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema } from "@shared/schema";
|
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema } from "@shared/schema";
|
||||||
|
import type { User, InsertUser } from "@shared/schema";
|
||||||
|
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
|
// Middleware pour vérifier l'authentification
|
||||||
|
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
|
if (!req.isAuthenticated()) {
|
||||||
|
return res.status(401).json({ error: "Non authentifié" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware pour vérifier le rôle admin
|
||||||
|
function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||||
|
if (!req.isAuthenticated()) {
|
||||||
|
return res.status(401).json({ error: "Non authentifié" });
|
||||||
|
}
|
||||||
|
const user = req.user as User;
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
return res.status(403).json({ error: "Accès refusé. Réservé aux administrateurs." });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerRoutes(app: Express): Promise<Server> {
|
export async function registerRoutes(app: Express): Promise<Server> {
|
||||||
|
|
||||||
// Stats endpoint
|
// Routes d'authentification
|
||||||
app.get("/api/stats", async (req, res) => {
|
app.post("/api/auth/login", (req, res, next) => {
|
||||||
|
passport.authenticate("local", (err: any, user: User | false, info: any) => {
|
||||||
|
if (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: info?.message || "Authentification échouée" });
|
||||||
|
}
|
||||||
|
req.logIn(user, (err) => {
|
||||||
|
if (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
return res.json({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/auth/logout", (req, res) => {
|
||||||
|
req.logout((err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: "Erreur lors de la déconnexion" });
|
||||||
|
}
|
||||||
|
res.json({ message: "Déconnecté avec succès" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/auth/session", (req, res) => {
|
||||||
|
if (req.isAuthenticated()) {
|
||||||
|
const user = req.user as User;
|
||||||
|
res.json({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: "Non authentifié" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour obtenir la liste de tous les utilisateurs (réservée aux admins)
|
||||||
|
app.get("/api/users", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// For now, return mock stats. In a real app, calculate from database
|
const allUsers = await storage.getAllUsers();
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
|
||||||
|
// Ne pas envoyer les mots de passe
|
||||||
|
const safeUsers = allUsers.map(user => ({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(safeUsers);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching users:", error);
|
||||||
|
res.status(500).json({ error: "Erreur lors de la récupération des utilisateurs" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour créer un nouvel utilisateur (réservée aux admins)
|
||||||
|
app.post("/api/users", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Validation Zod
|
||||||
|
const createUserSchema = insertUserSchema.extend({
|
||||||
|
username: insertUserSchema.shape.username.min(3, "Le nom d'utilisateur doit contenir au moins 3 caractères"),
|
||||||
|
password: insertUserSchema.shape.password.min(4, "Le mot de passe doit contenir au moins 4 caractères"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const validatedData = createUserSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur existe déjà
|
||||||
|
const existingUser = await storage.getUserByUsername(validatedData.username);
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(409).json({ error: "Ce nom d'utilisateur existe déjà" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hasher le mot de passe
|
||||||
|
const hashedPassword = await bcrypt.hash(validatedData.password, 10);
|
||||||
|
|
||||||
|
const newUser = await storage.createUser({
|
||||||
|
username: validatedData.username,
|
||||||
|
password: hashedPassword,
|
||||||
|
role: validatedData.role || "user",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: newUser.id,
|
||||||
|
username: newUser.username,
|
||||||
|
role: newUser.role,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error creating user:", error);
|
||||||
|
if (error.name === 'ZodError') {
|
||||||
|
return res.status(400).json({ error: error.errors[0]?.message || "Données invalides" });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: "Erreur lors de la création de l'utilisateur" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour modifier un utilisateur (réservée aux admins)
|
||||||
|
app.patch("/api/users/:id", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.params.id;
|
||||||
|
const { username, password, role } = req.body;
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur existe
|
||||||
|
const existingUser = await storage.getUser(userId);
|
||||||
|
if (!existingUser) {
|
||||||
|
return res.status(404).json({ error: "Utilisateur non trouvé" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Partial<InsertUser> = {};
|
||||||
|
|
||||||
|
if (username && username !== existingUser.username) {
|
||||||
|
// Vérifier si le nouveau username est déjà pris
|
||||||
|
const userWithSameUsername = await storage.getUserByUsername(username);
|
||||||
|
if (userWithSameUsername && userWithSameUsername.id !== userId) {
|
||||||
|
return res.status(409).json({ error: "Ce nom d'utilisateur est déjà utilisé" });
|
||||||
|
}
|
||||||
|
updateData.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
// Hasher le nouveau mot de passe
|
||||||
|
updateData.password = await bcrypt.hash(password, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role && (role === "admin" || role === "user")) {
|
||||||
|
updateData.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length === 0) {
|
||||||
|
return res.status(400).json({ error: "Aucune modification fournie" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await storage.updateUser(userId, updateData);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: updatedUser.id,
|
||||||
|
username: updatedUser.username,
|
||||||
|
role: updatedUser.role,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error updating user:", error);
|
||||||
|
res.status(500).json({ error: "Erreur lors de la modification de l'utilisateur" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour supprimer un utilisateur (réservée aux admins)
|
||||||
|
app.delete("/api/users/:id", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.params.id;
|
||||||
|
const currentUser = req.user as User;
|
||||||
|
|
||||||
|
// Empêcher l'admin de se supprimer lui-même
|
||||||
|
if (userId === currentUser.id) {
|
||||||
|
return res.status(400).json({ error: "Vous ne pouvez pas supprimer votre propre compte" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur existe
|
||||||
|
const existingUser = await storage.getUser(userId);
|
||||||
|
if (!existingUser) {
|
||||||
|
return res.status(404).json({ error: "Utilisateur non trouvé" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.deleteUser(userId);
|
||||||
|
|
||||||
|
res.json({ success: true, message: "Utilisateur supprimé avec succès" });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error deleting user:", error);
|
||||||
|
res.status(500).json({ error: "Erreur lors de la suppression de l'utilisateur" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour vérifier si le mot de passe admin par défaut a été changé
|
||||||
|
app.get("/api/auth/default-password-status", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const adminUser = await storage.getUserByUsername("admin");
|
||||||
|
|
||||||
|
if (!adminUser) {
|
||||||
|
return res.json({ isDefault: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si le mot de passe correspond à "admin"
|
||||||
|
const isDefaultPassword = await bcrypt.compare("admin", adminUser.password);
|
||||||
|
|
||||||
|
res.json({ isDefault: isDefaultPassword });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking default password:", error);
|
||||||
|
res.status(500).json({ error: "Erreur lors de la vérification" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route SQL (réservée aux admins)
|
||||||
|
app.post("/api/sql/execute", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Validation Zod
|
||||||
|
const sqlQuerySchema = z.object({
|
||||||
|
query: z.string().min(1, "La requête SQL ne peut pas être vide"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const validatedData = sqlQuerySchema.parse(req.body);
|
||||||
|
|
||||||
|
const { db } = await import("./db");
|
||||||
|
const result = await db.execute(validatedData.query);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error executing SQL:", error);
|
||||||
|
if (error.name === 'ZodError') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error.errors[0]?.message || "Données invalides",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Erreur lors de l'exécution de la requête SQL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route pour obtenir la liste des tables (réservée aux admins)
|
||||||
|
app.get("/api/sql/tables", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { db } = await import("./db");
|
||||||
|
const result = await db.execute<{ tablename: string }>(
|
||||||
|
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
tables: result.rows || result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error fetching tables:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: error.message || "Erreur lors de la récupération des tables",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Stats endpoint
|
||||||
|
app.get("/api/stats", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
|
|
||||||
const posts = await storage.getPosts(userId);
|
const posts = await storage.getPosts(userId);
|
||||||
const pages = await storage.getSocialPages(userId);
|
const pages = await storage.getSocialPages(userId);
|
||||||
@@ -36,12 +309,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// AI text generation
|
// AI text generation
|
||||||
app.post("/api/ai/generate", async (req, res) => {
|
app.post("/api/ai/generate", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
const productInfo = req.body;
|
const productInfo = req.body;
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
|
||||||
|
|
||||||
const generatedTexts = await openRouterService.generatePostText(productInfo);
|
const generatedTexts = await openRouterService.generatePostText(productInfo, userId);
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
await storage.createAiGeneration({
|
await storage.createAiGeneration({
|
||||||
@@ -58,13 +332,14 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Media upload
|
// Media upload
|
||||||
app.post("/api/media/upload", upload.single("file"), async (req, res) => {
|
app.post("/api/media/upload", requireAuth, upload.single("file"), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: "No file uploaded" });
|
return res.status(400).json({ error: "No file uploaded" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
|
|
||||||
// Check if Cloudinary is configured
|
// Check if Cloudinary is configured
|
||||||
const cloudinaryConfig = await storage.getCloudinaryConfig(userId);
|
const cloudinaryConfig = await storage.getCloudinaryConfig(userId);
|
||||||
@@ -104,9 +379,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get media
|
// Get media
|
||||||
app.get("/api/media", async (req, res) => {
|
app.get("/api/media", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
const media = await storage.getMedia(userId);
|
const media = await storage.getMedia(userId);
|
||||||
res.json(media);
|
res.json(media);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -116,9 +392,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete media
|
// Delete media
|
||||||
app.delete("/api/media/:id", async (req, res) => {
|
app.delete("/api/media/:id", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
const mediaId = req.params.id;
|
const mediaId = req.params.id;
|
||||||
|
|
||||||
// Get media to find cloudinary public ID
|
// Get media to find cloudinary public ID
|
||||||
@@ -149,9 +426,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Posts
|
// Posts
|
||||||
app.get("/api/posts", async (req, res) => {
|
app.get("/api/posts", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
const posts = await storage.getPosts(userId);
|
const posts = await storage.getPosts(userId);
|
||||||
res.json(posts);
|
res.json(posts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -160,9 +438,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/posts", async (req, res) => {
|
app.post("/api/posts", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
const postData = insertPostSchema.parse({ ...req.body, userId });
|
const postData = insertPostSchema.parse({ ...req.body, userId });
|
||||||
const post = await storage.createPost(postData);
|
const post = await storage.createPost(postData);
|
||||||
res.json(post);
|
res.json(post);
|
||||||
@@ -173,9 +452,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Scheduled posts
|
// Scheduled posts
|
||||||
app.get("/api/scheduled-posts", async (req, res) => {
|
app.get("/api/scheduled-posts", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
const { startDate, endDate } = req.query;
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
const start = startDate ? new Date(startDate as string) : undefined;
|
const start = startDate ? new Date(startDate as string) : undefined;
|
||||||
@@ -189,7 +469,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/scheduled-posts", async (req, res) => {
|
app.post("/api/scheduled-posts", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const scheduledPostData = insertScheduledPostSchema.parse(req.body);
|
const scheduledPostData = insertScheduledPostSchema.parse(req.body);
|
||||||
const scheduledPost = await storage.createScheduledPost(scheduledPostData);
|
const scheduledPost = await storage.createScheduledPost(scheduledPostData);
|
||||||
@@ -201,9 +481,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Social pages
|
// Social pages
|
||||||
app.get("/api/pages", async (req, res) => {
|
app.get("/api/pages", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
const pages = await storage.getSocialPages(userId);
|
const pages = await storage.getSocialPages(userId);
|
||||||
res.json(pages);
|
res.json(pages);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -212,9 +493,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/pages", async (req, res) => {
|
app.post("/api/pages", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
const pageData = insertSocialPageSchema.parse({ ...req.body, userId });
|
const pageData = insertSocialPageSchema.parse({ ...req.body, userId });
|
||||||
const page = await storage.createSocialPage(pageData);
|
const page = await storage.createSocialPage(pageData);
|
||||||
res.json(page);
|
res.json(page);
|
||||||
@@ -225,9 +507,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// AI Generations
|
// AI Generations
|
||||||
app.get("/api/ai/generations", async (req, res) => {
|
app.get("/api/ai/generations", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
const generations = await storage.getAiGenerations(userId);
|
const generations = await storage.getAiGenerations(userId);
|
||||||
res.json(generations);
|
res.json(generations);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -237,9 +520,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Cloudinary Config
|
// Cloudinary Config
|
||||||
app.get("/api/cloudinary/config", async (req, res) => {
|
app.get("/api/cloudinary/config", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
const config = await storage.getCloudinaryConfig(userId);
|
const config = await storage.getCloudinaryConfig(userId);
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -255,9 +539,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/cloudinary/config", async (req, res) => {
|
app.post("/api/cloudinary/config", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
|
|
||||||
const configData = insertCloudinaryConfigSchema.parse({
|
const configData = insertCloudinaryConfigSchema.parse({
|
||||||
...req.body,
|
...req.body,
|
||||||
@@ -283,6 +568,88 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// OpenRouter models list
|
||||||
|
app.get("/api/openrouter/models", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://openrouter.ai/api/v1/models", {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`OpenRouter API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching OpenRouter models:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch OpenRouter models" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OpenRouter configuration
|
||||||
|
app.get("/api/openrouter/config", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
|
const config = await storage.getOpenrouterConfig(userId);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return res.json(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't send the API key to the client
|
||||||
|
const { apiKey, ...safeConfig } = config;
|
||||||
|
res.json(safeConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching OpenRouter config:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch OpenRouter config" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/openrouter/config", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const userId = user.id;
|
||||||
|
|
||||||
|
// Check if config already exists
|
||||||
|
const existingConfig = await storage.getOpenrouterConfig(userId);
|
||||||
|
|
||||||
|
let config;
|
||||||
|
if (existingConfig) {
|
||||||
|
// Pour les mises à jour, utiliser le schéma qui rend apiKey optionnel
|
||||||
|
const updateData = updateOpenrouterConfigSchema.parse({
|
||||||
|
...req.body,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Si apiKey n'est pas fourni, garder l'ancien
|
||||||
|
const finalData = {
|
||||||
|
...updateData,
|
||||||
|
apiKey: updateData.apiKey || existingConfig.apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
config = await storage.updateOpenrouterConfig(userId, finalData);
|
||||||
|
} else {
|
||||||
|
// Pour les créations, exiger tous les champs
|
||||||
|
const configData = insertOpenrouterConfigSchema.parse({
|
||||||
|
...req.body,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
config = await storage.createOpenrouterConfig(configData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't send the API key back
|
||||||
|
const { apiKey, ...safeConfig } = config;
|
||||||
|
res.json(safeConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving OpenRouter config:", error);
|
||||||
|
res.status(500).json({ error: "Failed to save OpenRouter config" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
|
|
||||||
return httpServer;
|
return httpServer;
|
||||||
|
|||||||
@@ -12,35 +12,32 @@ interface GeneratedText {
|
|||||||
characterCount: number;
|
characterCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { storage } from '../storage';
|
||||||
|
|
||||||
export class OpenRouterService {
|
export class OpenRouterService {
|
||||||
private apiKey: string;
|
|
||||||
private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
|
private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
|
||||||
|
|
||||||
constructor() {
|
async generatePostText(productInfo: ProductInfo, userId: string): Promise<GeneratedText[]> {
|
||||||
this.apiKey = process.env.OPENROUTER_API_KEY || "";
|
// Get user's OpenRouter configuration
|
||||||
if (!this.apiKey) {
|
const config = await storage.getOpenrouterConfig(userId);
|
||||||
console.warn("OPENROUTER_API_KEY not set. AI text generation will not work.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async generatePostText(productInfo: ProductInfo): Promise<GeneratedText[]> {
|
if (!config) {
|
||||||
if (!this.apiKey) {
|
throw new Error('Configuration OpenRouter non trouvée. Veuillez configurer OpenRouter dans les paramètres.');
|
||||||
throw new Error("OpenRouter API key not configured");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = this.buildPrompt(productInfo);
|
const prompt = this.buildPrompt(productInfo, config.systemPrompt);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(this.baseUrl, {
|
const response = await fetch(this.baseUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${this.apiKey}`,
|
"Authorization": `Bearer ${config.apiKey}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"HTTP-Referer": process.env.APP_URL || "http://localhost:5000",
|
"HTTP-Referer": process.env.APP_URL || "http://localhost:5555",
|
||||||
"X-Title": "Social Flow"
|
"X-Title": "Social Flow"
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: "anthropic/claude-3.5-sonnet",
|
model: config.model,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -66,26 +63,18 @@ export class OpenRouterService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildPrompt(productInfo: ProductInfo): string {
|
private buildPrompt(productInfo: ProductInfo, systemPrompt: string): string {
|
||||||
const features = productInfo.features?.join(", ") || "";
|
const features = productInfo.features?.join(", ") || "";
|
||||||
|
|
||||||
return `Tu es un expert en marketing sur les réseaux sociaux. Génère 3 variantes de texte pour une publication Facebook/Instagram pour ce produit:
|
const productDetails = `Nom: ${productInfo.name}
|
||||||
|
|
||||||
Nom: ${productInfo.name}
|
|
||||||
${productInfo.description ? `Description: ${productInfo.description}` : ''}
|
${productInfo.description ? `Description: ${productInfo.description}` : ''}
|
||||||
${productInfo.price ? `Prix: ${productInfo.price}` : ''}
|
${productInfo.price ? `Prix: ${productInfo.price}` : ''}
|
||||||
${features ? `Caractéristiques: ${features}` : ''}
|
${features ? `Caractéristiques: ${features}` : ''}`;
|
||||||
|
|
||||||
Crée 3 versions différentes:
|
return `${systemPrompt}
|
||||||
1. Version Dynamique - Énergique et engageante avec des emojis
|
|
||||||
2. Version Informative - Factuelle et professionnelle
|
|
||||||
3. Version Émotionnelle - Axée sur les bénéfices émotionnels
|
|
||||||
|
|
||||||
Chaque version doit:
|
Informations produit:
|
||||||
- Être en français
|
${productDetails}
|
||||||
- Faire entre 150-250 caractères
|
|
||||||
- Être optimisée pour les réseaux sociaux
|
|
||||||
- Inclure des emojis pertinents (sauf la version informative)
|
|
||||||
|
|
||||||
Format de réponse:
|
Format de réponse:
|
||||||
VERSION 1 - DYNAMIQUE:
|
VERSION 1 - DYNAMIQUE:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
scheduledPosts,
|
scheduledPosts,
|
||||||
aiGenerations,
|
aiGenerations,
|
||||||
cloudinaryConfig,
|
cloudinaryConfig,
|
||||||
|
openrouterConfig,
|
||||||
type User,
|
type User,
|
||||||
type InsertUser,
|
type InsertUser,
|
||||||
type SocialPage,
|
type SocialPage,
|
||||||
@@ -20,6 +21,8 @@ import {
|
|||||||
type InsertAiGeneration,
|
type InsertAiGeneration,
|
||||||
type CloudinaryConfig,
|
type CloudinaryConfig,
|
||||||
type InsertCloudinaryConfig,
|
type InsertCloudinaryConfig,
|
||||||
|
type OpenrouterConfig,
|
||||||
|
type InsertOpenrouterConfig,
|
||||||
} from "@shared/schema";
|
} from "@shared/schema";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { eq, and, gte, lte, desc, isNull } from "drizzle-orm";
|
import { eq, and, gte, lte, desc, isNull } from "drizzle-orm";
|
||||||
@@ -28,7 +31,10 @@ export interface IStorage {
|
|||||||
// Users
|
// Users
|
||||||
getUser(id: string): Promise<User | undefined>;
|
getUser(id: string): Promise<User | undefined>;
|
||||||
getUserByUsername(username: string): Promise<User | undefined>;
|
getUserByUsername(username: string): Promise<User | undefined>;
|
||||||
|
getAllUsers(): Promise<User[]>;
|
||||||
createUser(user: InsertUser): Promise<User>;
|
createUser(user: InsertUser): Promise<User>;
|
||||||
|
updateUser(id: string, user: Partial<InsertUser>): Promise<User>;
|
||||||
|
deleteUser(id: string): Promise<void>;
|
||||||
|
|
||||||
// Social Pages
|
// Social Pages
|
||||||
getSocialPages(userId: string): Promise<SocialPage[]>;
|
getSocialPages(userId: string): Promise<SocialPage[]>;
|
||||||
@@ -66,6 +72,11 @@ export interface IStorage {
|
|||||||
getCloudinaryConfig(userId: string): Promise<CloudinaryConfig | undefined>;
|
getCloudinaryConfig(userId: string): Promise<CloudinaryConfig | undefined>;
|
||||||
createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>;
|
createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>;
|
||||||
updateCloudinaryConfig(userId: string, config: Partial<InsertCloudinaryConfig>): Promise<CloudinaryConfig>;
|
updateCloudinaryConfig(userId: string, config: Partial<InsertCloudinaryConfig>): Promise<CloudinaryConfig>;
|
||||||
|
|
||||||
|
// OpenRouter Config
|
||||||
|
getOpenrouterConfig(userId: string): Promise<OpenrouterConfig | undefined>;
|
||||||
|
createOpenrouterConfig(config: InsertOpenrouterConfig): Promise<OpenrouterConfig>;
|
||||||
|
updateOpenrouterConfig(userId: string, config: Partial<InsertOpenrouterConfig>): Promise<OpenrouterConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DatabaseStorage implements IStorage {
|
export class DatabaseStorage implements IStorage {
|
||||||
@@ -80,11 +91,24 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return user || undefined;
|
return user || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllUsers(): Promise<User[]> {
|
||||||
|
return await db.select().from(users);
|
||||||
|
}
|
||||||
|
|
||||||
async createUser(insertUser: InsertUser): Promise<User> {
|
async createUser(insertUser: InsertUser): Promise<User> {
|
||||||
const [user] = await db.insert(users).values(insertUser).returning();
|
const [user] = await db.insert(users).values(insertUser).returning();
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateUser(id: string, updateData: Partial<InsertUser>): Promise<User> {
|
||||||
|
const [updated] = await db.update(users).set(updateData).where(eq(users.id, id)).returning();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(id: string): Promise<void> {
|
||||||
|
await db.delete(users).where(eq(users.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
// Social Pages
|
// Social Pages
|
||||||
async getSocialPages(userId: string): Promise<SocialPage[]> {
|
async getSocialPages(userId: string): Promise<SocialPage[]> {
|
||||||
return await db.select().from(socialPages).where(eq(socialPages.userId, userId));
|
return await db.select().from(socialPages).where(eq(socialPages.userId, userId));
|
||||||
@@ -235,6 +259,25 @@ export class DatabaseStorage implements IStorage {
|
|||||||
.returning();
|
.returning();
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenRouter Config
|
||||||
|
async getOpenrouterConfig(userId: string): Promise<OpenrouterConfig | undefined> {
|
||||||
|
const [config] = await db.select().from(openrouterConfig).where(eq(openrouterConfig.userId, userId));
|
||||||
|
return config || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpenrouterConfig(config: InsertOpenrouterConfig): Promise<OpenrouterConfig> {
|
||||||
|
const [newConfig] = await db.insert(openrouterConfig).values(config).returning();
|
||||||
|
return newConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOpenrouterConfig(userId: string, config: Partial<InsertOpenrouterConfig>): Promise<OpenrouterConfig> {
|
||||||
|
const [updated] = await db.update(openrouterConfig)
|
||||||
|
.set({ ...config, updatedAt: new Date() })
|
||||||
|
.where(eq(openrouterConfig.userId, userId))
|
||||||
|
.returning();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storage = new DatabaseStorage();
|
export const storage = new DatabaseStorage();
|
||||||
@@ -8,11 +8,13 @@ export const platformEnum = pgEnum("platform", ["facebook", "instagram"]);
|
|||||||
export const postTypeEnum = pgEnum("post_type", ["feed", "story"]);
|
export const postTypeEnum = pgEnum("post_type", ["feed", "story"]);
|
||||||
export const postStatusEnum = pgEnum("post_status", ["draft", "scheduled", "published", "failed"]);
|
export const postStatusEnum = pgEnum("post_status", ["draft", "scheduled", "published", "failed"]);
|
||||||
export const mediaTypeEnum = pgEnum("media_type", ["image", "video"]);
|
export const mediaTypeEnum = pgEnum("media_type", ["image", "video"]);
|
||||||
|
export const userRoleEnum = pgEnum("user_role", ["admin", "user"]);
|
||||||
|
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
username: text("username").notNull().unique(),
|
username: text("username").notNull().unique(),
|
||||||
password: text("password").notNull(),
|
password: text("password").notNull(),
|
||||||
|
role: userRoleEnum("role").notNull().default("user"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const socialPages = pgTable("social_pages", {
|
export const socialPages = pgTable("social_pages", {
|
||||||
@@ -37,6 +39,16 @@ export const cloudinaryConfig = pgTable("cloudinary_config", {
|
|||||||
updatedAt: timestamp("updated_at").defaultNow(),
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const openrouterConfig = pgTable("openrouter_config", {
|
||||||
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
userId: varchar("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
apiKey: text("api_key").notNull(),
|
||||||
|
model: text("model").notNull().default("anthropic/claude-3.5-sonnet"),
|
||||||
|
systemPrompt: text("system_prompt").notNull().default("Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l'engagement."),
|
||||||
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
export const media = pgTable("media", {
|
export const media = pgTable("media", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
@@ -96,6 +108,7 @@ export const usersRelations = relations(users, ({ many, one }) => ({
|
|||||||
posts: many(posts),
|
posts: many(posts),
|
||||||
aiGenerations: many(aiGenerations),
|
aiGenerations: many(aiGenerations),
|
||||||
cloudinaryConfig: one(cloudinaryConfig),
|
cloudinaryConfig: one(cloudinaryConfig),
|
||||||
|
openrouterConfig: one(openrouterConfig),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const cloudinaryConfigRelations = relations(cloudinaryConfig, ({ one }) => ({
|
export const cloudinaryConfigRelations = relations(cloudinaryConfig, ({ one }) => ({
|
||||||
@@ -105,6 +118,13 @@ export const cloudinaryConfigRelations = relations(cloudinaryConfig, ({ one }) =
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const openrouterConfigRelations = relations(openrouterConfig, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [openrouterConfig.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
export const socialPagesRelations = relations(socialPages, ({ one, many }) => ({
|
export const socialPagesRelations = relations(socialPages, ({ one, many }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [socialPages.userId],
|
fields: [socialPages.userId],
|
||||||
@@ -163,6 +183,7 @@ export const aiGenerationsRelations = relations(aiGenerations, ({ one }) => ({
|
|||||||
export const insertUserSchema = createInsertSchema(users).pick({
|
export const insertUserSchema = createInsertSchema(users).pick({
|
||||||
username: true,
|
username: true,
|
||||||
password: true,
|
password: true,
|
||||||
|
role: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const insertSocialPageSchema = createInsertSchema(socialPages).omit({
|
export const insertSocialPageSchema = createInsertSchema(socialPages).omit({
|
||||||
@@ -199,6 +220,16 @@ export const insertCloudinaryConfigSchema = createInsertSchema(cloudinaryConfig)
|
|||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const insertOpenrouterConfigSchema = createInsertSchema(openrouterConfig).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateOpenrouterConfigSchema = insertOpenrouterConfigSchema.partial({
|
||||||
|
apiKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
export type InsertUser = z.infer<typeof insertUserSchema>;
|
export type InsertUser = z.infer<typeof insertUserSchema>;
|
||||||
@@ -220,3 +251,6 @@ export type InsertAiGeneration = z.infer<typeof insertAiGenerationSchema>;
|
|||||||
|
|
||||||
export type CloudinaryConfig = typeof cloudinaryConfig.$inferSelect;
|
export type CloudinaryConfig = typeof cloudinaryConfig.$inferSelect;
|
||||||
export type InsertCloudinaryConfig = z.infer<typeof insertCloudinaryConfigSchema>;
|
export type InsertCloudinaryConfig = z.infer<typeof insertCloudinaryConfigSchema>;
|
||||||
|
|
||||||
|
export type OpenrouterConfig = typeof openrouterConfig.$inferSelect;
|
||||||
|
export type InsertOpenrouterConfig = z.infer<typeof insertOpenrouterConfigSchema>;
|
||||||
Reference in new issue
Block a user