Protect user data entry with an administrator lock feature
Introduce an admin lock mechanism to prevent unauthorized modifications to timesheet entries and quick entry fields. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 55837015-10e9-4be9-b857-7f5e6be73772 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: d79c5670-4ff2-409b-85ef-fc3f2472208b Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1cc377db-7ea0-49f2-97ce-c3e87e0228cc/55837015-10e9-4be9-b857-7f5e6be73772/LZewR4B Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
da5775ff17
commit
2586c0eb09
@ -1,14 +1,14 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { Download, FolderKanban, LayoutDashboard, Settings, Upload, Clock } from "lucide-react";
|
import { Download, FolderKanban, LayoutDashboard, Lock, LockOpen, Settings, Upload, Clock } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { QuickEntryButton } from "@/components/quick-entry";
|
import { QuickEntryButton } from "@/components/quick-entry";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { getAppInfo, saveAppInfo, type AppInfo } from "@/lib/app-info";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { isAdminUnlocked, lockAdmin, unlockAdmin, useAdminUnlocked } from "@/lib/admin-mode";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
@ -31,22 +31,38 @@ const navItems = [
|
|||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const [isAdminOpen, setIsAdminOpen] = useState(false);
|
const [isAdminOpen, setIsAdminOpen] = useState(false);
|
||||||
const [info, setInfo] = useState<AppInfo>(() => getAppInfo());
|
const [adminCode, setAdminCode] = useState("");
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const adminUnlocked = useAdminUnlocked();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const handleOpenAdmin = () => {
|
const handleOpenAdmin = () => {
|
||||||
setInfo(getAppInfo());
|
setAdminCode("");
|
||||||
setIsAdminOpen(true);
|
setIsAdminOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveInfo = () => {
|
const handleUnlock = () => {
|
||||||
saveAppInfo(info);
|
if (unlockAdmin(adminCode)) {
|
||||||
toast({ title: "Informations mises à jour" });
|
setAdminCode("");
|
||||||
|
toast({ title: "Saisie déverrouillée" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({ title: "Code admin incorrect", variant: "destructive" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLock = () => {
|
||||||
|
lockAdmin();
|
||||||
|
toast({ title: "Saisie verrouillée" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
|
if (!isAdminUnlocked()) {
|
||||||
|
toast({ title: "Déverrouillez d'abord le mode admin", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch("/api/admin/export");
|
const response = await fetch("/api/admin/export");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast({ title: "Export impossible", variant: "destructive" });
|
toast({ title: "Export impossible", variant: "destructive" });
|
||||||
@ -64,6 +80,10 @@ export function AppSidebar() {
|
|||||||
|
|
||||||
const handleImport = async (file: File | undefined) => {
|
const handleImport = async (file: File | undefined) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
if (!isAdminUnlocked()) {
|
||||||
|
toast({ title: "Déverrouillez d'abord le mode admin", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!confirm("Importer ce fichier remplacera toutes les données actuelles. Continuer ?")) return;
|
if (!confirm("Importer ce fichier remplacera toutes les données actuelles. Continuer ?")) return;
|
||||||
|
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
@ -123,21 +143,30 @@ export function AppSidebar() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenAdmin}
|
onClick={handleOpenAdmin}
|
||||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors cursor-pointer text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors cursor-pointer",
|
||||||
|
adminUnlocked
|
||||||
|
? "bg-green-50 text-green-700 hover:bg-green-100"
|
||||||
|
: "text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
{adminUnlocked ? (
|
||||||
|
<LockOpen className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||||
Admin
|
)}
|
||||||
|
{adminUnlocked ? "Admin actif" : "Admin"}
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-sidebar-border">
|
<div className="p-4 border-t border-sidebar-border">
|
||||||
<div className="flex items-center gap-3 px-3 py-2">
|
<div className="flex items-center gap-3 px-3 py-2">
|
||||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||||
{info.userInitials}
|
JD
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium">{info.userName}</span>
|
<span className="text-sm font-medium">Jean Dupont</span>
|
||||||
<span className="text-xs text-muted-foreground">{info.userRole}</span>
|
<span className="text-xs text-muted-foreground">Consultant</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -150,32 +179,47 @@ export function AppSidebar() {
|
|||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold">Informations affichées</h3>
|
<h3 className="text-sm font-semibold">Protection de la saisie</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{adminUnlocked ? (
|
||||||
<div className="space-y-1.5">
|
<div className="rounded-md border border-green-200 bg-green-50 p-3 text-sm text-green-800">
|
||||||
<Label>Nom</Label>
|
La saisie est déverrouillée. Les cellules CRA et la saisie rapide sont modifiables.
|
||||||
<Input value={info.userName} onChange={(e) => setInfo({ ...info, userName: e.target.value })} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
) : (
|
||||||
<Label>Rôle</Label>
|
<div className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||||
<Input value={info.userRole} onChange={(e) => setInfo({ ...info, userRole: e.target.value })} />
|
La saisie est verrouillée. Déverrouillez le mode admin pour modifier les heures.
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
)}
|
||||||
<Label>Initiales</Label>
|
<div className="flex gap-2">
|
||||||
<Input value={info.userInitials} onChange={(e) => setInfo({ ...info, userInitials: e.target.value })} />
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Label>Code admin</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={adminCode}
|
||||||
|
onChange={(e) => setAdminCode(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleUnlock();
|
||||||
|
}}
|
||||||
|
placeholder="Code admin"
|
||||||
|
disabled={adminUnlocked}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="flex items-end">
|
||||||
<Label>Date de déploiement</Label>
|
{adminUnlocked ? (
|
||||||
<Input value={info.deployDate} onChange={(e) => setInfo({ ...info, deployDate: e.target.value })} />
|
<Button variant="outline" onClick={handleLock} className="gap-2">
|
||||||
</div>
|
<Lock className="h-4 w-4" />
|
||||||
<div className="space-y-1.5 col-span-2">
|
Verrouiller
|
||||||
<Label>Lien du repo</Label>
|
|
||||||
<Input value={info.repoUrl} onChange={(e) => setInfo({ ...info, repoUrl: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" onClick={handleSaveInfo}>
|
|
||||||
Enregistrer les informations
|
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleUnlock} className="gap-2">
|
||||||
|
<LockOpen className="h-4 w-4" />
|
||||||
|
Déverrouiller
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Code par défaut : 1234
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 border-t pt-4">
|
<div className="space-y-3 border-t pt-4">
|
||||||
@ -189,7 +233,7 @@ export function AppSidebar() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={isImporting}
|
disabled={isImporting || !adminUnlocked}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
|
|||||||
@ -27,24 +27,39 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Zap, Clock, Check } from "lucide-react";
|
import { Zap, Clock, Check, Lock } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
|
import { useAdminUnlocked } from "@/lib/admin-mode";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const HOUR_OPTIONS = [0.5, 1, 2, 3, 4, 5, 6, 7, 7.7];
|
const HOUR_OPTIONS = [0.5, 1, 2, 3, 4, 5, 6, 7, 8];
|
||||||
const COLLABORATOR = "PHAM Sylvain";
|
const COLLABORATOR = "PHAM Sylvain";
|
||||||
|
|
||||||
export function QuickEntryButton() {
|
export function QuickEntryButton() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const adminUnlocked = useAdminUnlocked();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => {
|
||||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors cursor-pointer w-full bg-primary/10 text-primary hover:bg-primary/20"
|
if (!adminUnlocked) {
|
||||||
|
toast({ title: "Saisie verrouillée", description: "Déverrouillez le mode admin pour saisir des heures." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors cursor-pointer w-full",
|
||||||
|
adminUnlocked
|
||||||
|
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||||
|
: "text-sidebar-foreground/50 bg-muted/40"
|
||||||
|
)}
|
||||||
data-testid="button-quick-entry"
|
data-testid="button-quick-entry"
|
||||||
>
|
>
|
||||||
<Zap className="h-4 w-4" />
|
{adminUnlocked ? <Zap className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
|
||||||
Saisie rapide
|
Saisie rapide
|
||||||
</button>
|
</button>
|
||||||
<QuickEntryDialog open={open} onOpenChange={setOpen} />
|
<QuickEntryDialog open={open} onOpenChange={setOpen} />
|
||||||
|
|||||||
34
artifacts/cra-app/src/lib/admin-mode.ts
Normal file
34
artifacts/cra-app/src/lib/admin-mode.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const ADMIN_MODE_EVENT = "cra-admin-mode-updated";
|
||||||
|
const ADMIN_UNLOCKED_KEY = "cra-admin-unlocked";
|
||||||
|
const ADMIN_CODE = "1234";
|
||||||
|
|
||||||
|
export function isAdminUnlocked() {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return window.sessionStorage.getItem(ADMIN_UNLOCKED_KEY) === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlockAdmin(code: string) {
|
||||||
|
if (code !== ADMIN_CODE) return false;
|
||||||
|
window.sessionStorage.setItem(ADMIN_UNLOCKED_KEY, "true");
|
||||||
|
window.dispatchEvent(new Event(ADMIN_MODE_EVENT));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockAdmin() {
|
||||||
|
window.sessionStorage.removeItem(ADMIN_UNLOCKED_KEY);
|
||||||
|
window.dispatchEvent(new Event(ADMIN_MODE_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminUnlocked() {
|
||||||
|
const [unlocked, setUnlocked] = useState(() => isAdminUnlocked());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUpdate = () => setUnlocked(isAdminUnlocked());
|
||||||
|
window.addEventListener(ADMIN_MODE_EVENT, handleUpdate);
|
||||||
|
return () => window.removeEventListener(ADMIN_MODE_EVENT, handleUpdate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return unlocked;
|
||||||
|
}
|
||||||
@ -18,7 +18,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
MessageSquare
|
MessageSquare,
|
||||||
|
Lock
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@ -33,6 +34,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { formatMonthYear, STATUS_LABELS, STATUS_COLORS, cn } from "@/lib/utils";
|
import { formatMonthYear, STATUS_LABELS, STATUS_COLORS, cn } from "@/lib/utils";
|
||||||
|
import { useAdminUnlocked } from "@/lib/admin-mode";
|
||||||
import { getDaysInMonth, isWeekend, format } from "date-fns";
|
import { getDaysInMonth, isWeekend, format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
@ -49,6 +51,7 @@ export default function TimesheetDetailPage() {
|
|||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const adminUnlocked = useAdminUnlocked();
|
||||||
|
|
||||||
const { data: timesheet, isLoading } = useGetTimesheet(
|
const { data: timesheet, isLoading } = useGetTimesheet(
|
||||||
timesheetId,
|
timesheetId,
|
||||||
@ -108,9 +111,9 @@ export default function TimesheetDetailPage() {
|
|||||||
return {
|
return {
|
||||||
days: daysCount,
|
days: daysCount,
|
||||||
daysArray: arr,
|
daysArray: arr,
|
||||||
isEditable: timesheet.status === "draft"
|
isEditable: timesheet.status === "draft" && adminUnlocked
|
||||||
};
|
};
|
||||||
}, [timesheet]);
|
}, [timesheet, adminUnlocked]);
|
||||||
|
|
||||||
const HOUR_OPTIONS = [0, 0.5, 1, 2, 3, 4, 5, 6, 7, 8];
|
const HOUR_OPTIONS = [0, 0.5, 1, 2, 3, 4, 5, 6, 7, 8];
|
||||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
|
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
|
||||||
@ -346,6 +349,12 @@ export default function TimesheetDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{timesheet.status === "draft" && !adminUnlocked && (
|
||||||
|
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200 px-3 py-1.5 text-sm gap-1">
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
Saisie verrouillée
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{timesheet.status === "submitted" && (
|
{timesheet.status === "submitted" && (
|
||||||
<Badge className="bg-blue-100 text-blue-800 hover:bg-blue-100 border-none px-3 py-1.5 text-sm gap-1">
|
<Badge className="bg-blue-100 text-blue-800 hover:bg-blue-100 border-none px-3 py-1.5 text-sm gap-1">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
|||||||
BIN
attached_assets/image_1776768428924.png
Normal file
BIN
attached_assets/image_1776768428924.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@ -26,7 +26,7 @@ A French timesheet management application (CRA - Compte Rendu d'Activité) built
|
|||||||
- **CRA Grid**: Interactive calendar grid where rows = projects, columns = days of month. Click cells to open popover with hour options [0, 0.5, 1, 2, 3, 4, 5, 6, 7, 8]. Includes optional description per cell (amber dot indicator). Auto-save with debounce. Weekend distinction, row/column totals
|
- **CRA Grid**: Interactive calendar grid where rows = projects, columns = days of month. Click cells to open popover with hour options [0, 0.5, 1, 2, 3, 4, 5, 6, 7, 8]. Includes optional description per cell (amber dot indicator). Auto-save with debounce. Weekend distinction, row/column totals
|
||||||
- **Project Management**: CRUD for projects with code, name, client, category
|
- **Project Management**: CRUD for projects with code, name, client, category
|
||||||
- **Timesheet Workflow**: Draft → Submitted → Validated status flow
|
- **Timesheet Workflow**: Draft → Submitted → Validated status flow
|
||||||
- **Inline Administration**: Sidebar Admin dialog to edit displayed app/user metadata and export/import project, CRA, line, and time-entry data as JSON
|
- **Inline Administration**: Sidebar Admin dialog to lock/unlock time entry editing and export/import project, CRA, line, and time-entry data as JSON
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user