feat: fix registration 500, add forgot-password flow, frontend validation
Some checks failed
Build and Deploy / Backend Tests (push) Has been cancelled
Build and Deploy / Frontend Build Check (push) Has been cancelled
Build and Deploy / Build Docker Images (push) Has been cancelled
Build and Deploy / Deploy to Server (push) Has been cancelled

- Fix MissingGreenlet: sync_engine now uses psycopg2 instead of asyncpg
- Fix bcrypt/passlib compat: pin bcrypt<4.1 in requirements
- Fix legacy password_hash NOT NULL: alter column to nullable in migration
- Add frontend password validation (uppercase + lowercase + digit)
- Add forgot-password and reset-password backend endpoints
- Add forgot-password and reset-password frontend pages
- Add email_service.py (SMTP via admin settings)
- Add reset_token/reset_token_expires columns to User model
- Migrate legacy JSON-only users to DB on password reset request
- Mount data/ volume in docker-compose.local.yml for persistence
- Add production deployment config (Dockerfile, nginx, deploy.sh)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-05-01 16:23:51 +02:00
parent 26bd096a06
commit 2f7347b4db
25 changed files with 2765 additions and 64 deletions

View File

@@ -0,0 +1,172 @@
"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { Search, List, Loader2, Check } from "lucide-react";
import { Input } from "@/components/ui/input";
export interface ModelOption {
id: string;
name?: string;
owned_by?: string;
context_length?: number;
pricing?: { prompt?: string; completion?: string };
}
interface ModelComboboxProps {
value: string;
onChange: (value: string) => void;
models: ModelOption[];
isLoading: boolean;
onFetchModels: () => void;
providerLabel: string;
placeholder?: string;
}
export function ModelCombobox({
value,
onChange,
models,
isLoading,
onFetchModels,
providerLabel,
placeholder = "nom-du-modele",
}: ModelComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [filter, setFilter] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const [highlightIndex, setHighlightIndex] = useState(-1);
const filtered = React.useMemo(() => {
if (!filter.trim()) return models;
const q = filter.toLowerCase();
return models.filter(
(m) =>
m.id.toLowerCase().includes(q) ||
(m.name && m.name.toLowerCase().includes(q))
);
}, [models, filter]);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
useEffect(() => { setHighlightIndex(-1); }, [filter]);
useEffect(() => {
if (highlightIndex >= 0 && listRef.current) {
(listRef.current.children[highlightIndex] as HTMLElement)?.scrollIntoView({ block: "nearest" });
}
}, [highlightIndex]);
const handleBrowse = useCallback(() => {
if (!isOpen) onFetchModels();
setIsOpen(!isOpen);
}, [isOpen, onFetchModels]);
const handleSelect = useCallback(
(model: ModelOption) => {
onChange(model.id);
setFilter("");
setIsOpen(false);
},
[onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isOpen || filtered.length === 0) return;
if (e.key === "ArrowDown") { e.preventDefault(); setHighlightIndex((p) => Math.min(p + 1, filtered.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setHighlightIndex((p) => Math.max(p - 1, 0)); }
else if (e.key === "Enter" && highlightIndex >= 0) { e.preventDefault(); handleSelect(filtered[highlightIndex]); }
else if (e.key === "Escape") { setIsOpen(false); }
},
[isOpen, filtered, highlightIndex, handleSelect]
);
return (
<div ref={containerRef} className="relative">
<div className="flex gap-2">
<div className="flex-1">
<Input
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => { if (models.length > 0) setIsOpen(true); }}
onKeyDown={handleKeyDown}
leftIcon={<Search className="size-4" />}
loading={isLoading}
/>
</div>
<button
type="button"
onClick={handleBrowse}
disabled={isLoading}
className="flex items-center gap-1.5 px-3 h-10 rounded-lg border border-border-subtle bg-surface text-sm text-muted-foreground hover:bg-surface-elevated hover:text-foreground transition-colors disabled:opacity-50"
>
{isLoading ? <Loader2 className="size-4 animate-spin" /> : <List className="size-4" />}
<span className="hidden sm:inline">Parcourir</span>
</button>
</div>
{isOpen && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border-subtle bg-card shadow-xl max-h-72 overflow-hidden flex flex-col">
{/* Filter */}
<div className="p-2 border-b border-border-subtle">
<Input
placeholder={`Filtrer...`}
value={filter}
onChange={(e) => setFilter(e.target.value)}
leftIcon={<Search className="size-3.5" />}
className="h-8 text-xs"
/>
</div>
{/* List */}
<ul ref={listRef} className="overflow-y-auto flex-1">
{filtered.length === 0 ? (
<li className="px-3 py-4 text-xs text-muted-foreground text-center">
{models.length === 0 ? "Aucun modele recupere" : "Aucun modele ne correspond"}
</li>
) : (
filtered.map((model, i) => (
<li
key={model.id}
onClick={() => handleSelect(model)}
onMouseEnter={() => setHighlightIndex(i)}
className={`flex items-center justify-between px-3 py-2 text-xs cursor-pointer transition-colors ${
i === highlightIndex ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-surface-elevated"
} ${value === model.id ? "bg-primary/5" : ""}`}
>
<div className="flex-1 min-w-0">
<div className="font-mono truncate text-foreground">{model.id}</div>
{model.name && model.name !== model.id && (
<div className="text-muted-foreground truncate mt-0.5">{model.name}</div>
)}
{model.context_length && (
<span className="text-muted-foreground">ctx: {(model.context_length / 1000).toFixed(0)}k</span>
)}
</div>
{value === model.id && <Check className="size-3.5 text-primary flex-shrink-0 ml-2" />}
</li>
))
)}
</ul>
{models.length > 0 && (
<div className="px-3 py-1.5 border-t border-border-subtle text-xs text-muted-foreground text-center bg-card">
{filtered.length} modele{filtered.length !== 1 ? "s" : ""}
{filter ? ` sur ${models.length}` : ""} nom libre possible
</div>
)}
</div>
)}
</div>
);
}