Improve invited mode

This commit is contained in:
Arthur Belleville 2025-10-26 21:52:57 +01:00
parent e6dddc5734
commit ba8615e062
No known key found for this signature in database
15 changed files with 490 additions and 119 deletions

View file

@ -2,10 +2,10 @@ import { SessionProvider } from "@xtablo/shared/contexts/SessionContext";
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import { Toaster } from "@xtablo/ui/components/sonner";
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
import { NotFoundPage } from "./pages/NotFoundPage";
import { publicRoutes } from "./lib/publicRoutes";
import { routes } from "./lib/routes";
import { supabase } from "./lib/supabase";
import { NotFoundPage } from "./pages/NotFoundPage";
import { DatadogRumProvider } from "./providers/DatadogRumProvider";
import { UserStoreProvider } from "./providers/UserStoreProvider";

View file

@ -1,4 +1,5 @@
import { getLocalTimeZone, parseDate, today } from "@internationalized/date";
import { toast } from "@xtablo/shared";
import { Event, EventInsert } from "@xtablo/shared/types/events.types";
import { Button } from "@xtablo/ui/components/button";
import { DatePicker } from "@xtablo/ui/components/date-picker";
@ -26,7 +27,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useCreateEvents, useEvent, useUpdateEvent } from "../hooks/events";
import { useTablosList } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
const { t, i18n } = useTranslation("components");
@ -34,6 +35,7 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
const { data: event } = useEvent(event_id as string);
const user = useUser();
const isReadOnly = useIsReadOnlyUser();
const [searchParams] = useSearchParams();
const tablo_id = searchParams.get("tablo_id");
const dateFromParams = searchParams.get("date");
@ -237,6 +239,18 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
</Button>
<Button
onClick={() => {
if (isReadOnly) {
toast.add(
{
title: t("eventModal.errors.readOnly"),
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas modifier ou créer d'événement.",
type: "error",
},
{ timeout: 5000 }
);
return;
}
const eventName = formEvent?.title.trim() || t("eventModal.untitled");
if (mode === "edit" && event) {
updateEvent.mutate(
@ -247,7 +261,7 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
createEvents({ ...formEvent, title: eventName }, { onSuccess: () => onClose() });
}
}}
disabled={!formEvent?.tablo_id}
disabled={!formEvent?.tablo_id || isReadOnly}
>
{mode === "edit" ? t("eventModal.buttons.edit") : t("eventModal.buttons.save")}
</Button>

View file

@ -35,7 +35,7 @@ import { Link as RouterLink, useLocation } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { useLogout } from "../hooks/auth";
import { isProd, isStaging } from "../lib/env";
import { useUser } from "../providers/UserStoreProvider";
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
import { getXtabloIcon } from "../utils/iconHelpers";
import { ThemeSwitcher } from "./ThemeSwitcher";
@ -276,6 +276,7 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
const location = useLocation();
const isReadOnly = useIsReadOnlyUser();
const { t } = useTranslation("navigation");
type List<T> = T[];
@ -291,47 +292,61 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
| {
isHorizontalBar: boolean;
}
> = [
{
path: "/",
label: t("projects"),
icon: <PanelsTopLeft className="w-5 h-5" />,
},
{ isHorizontalBar: true },
{
path: "/events",
label: t("myEvents"),
icon: <CalendarCheckIcon className="w-5 h-5" />,
},
{
path: "/kanban",
label: t("kanban"),
icon: <Kanban className="w-5 h-5" />,
isDisabled: true,
},
{
path: "/chantiers",
label: t("chantiers"),
icon: <ConstructionIcon className="w-5 h-5" />,
isDisabled: true,
},
{ isHorizontalBar: true },
{
path: "/planning",
label: t("planning"),
icon: <SquareKanban className="w-5 h-5" />,
},
{
path: "/chat",
label: t("discussions"),
icon: <MessageCircleIcon className="w-5 h-5" />,
},
{
path: "/notes",
label: t("notes"),
icon: <FileTextIcon className="w-5 h-5" />,
},
];
> = isReadOnly
? [
{
path: "/",
label: t("projects"),
icon: <PanelsTopLeft className="w-5 h-5" />,
},
{
path: "/planning",
label: t("planning"),
icon: <SquareKanban className="w-5 h-5" />,
},
]
: [
{
path: "/",
label: t("projects"),
icon: <PanelsTopLeft className="w-5 h-5" />,
},
{ isHorizontalBar: true },
{
path: "/events",
label: t("myEvents"),
icon: <CalendarCheckIcon className="w-5 h-5" />,
isDisabled: isReadOnly,
},
{
path: "/kanban",
label: t("kanban"),
icon: <Kanban className="w-5 h-5" />,
isDisabled: true,
},
{
path: "/chantiers",
label: t("chantiers"),
icon: <ConstructionIcon className="w-5 h-5" />,
isDisabled: true,
},
{ isHorizontalBar: true },
{
path: "/planning",
label: t("planning"),
icon: <SquareKanban className="w-5 h-5" />,
},
{
path: "/chat",
label: t("discussions"),
icon: <MessageCircleIcon className="w-5 h-5" />,
},
{
path: "/notes",
label: t("notes"),
icon: <FileTextIcon className="w-5 h-5" />,
},
];
return (
<nav className="flex flex-1 flex-col" aria-label="Primary navigation">
<ul role="list" className={twMerge("grid py-3", isCollapsed ? "pl-2.5 pr-3" : "")}>

View file

@ -5,11 +5,19 @@ import { useMaybeUser } from "../providers/UserStoreProvider";
import { LoadingSpinner } from "./LoadingSpinner";
interface ProtectedRouteProps {
// Fallback URL
fallback?: string;
// Only allow regular users (not temporary)
onlyRegularUser?: boolean;
// Redirect to current page
shouldRedirectToCurrentPage?: boolean;
}
export const ProtectedRoute = ({ fallback, shouldRedirectToCurrentPage }: ProtectedRouteProps) => {
export const ProtectedRoute = ({
fallback,
onlyRegularUser,
shouldRedirectToCurrentPage,
}: ProtectedRouteProps) => {
const user = useMaybeUser();
const [isLoading, setIsLoading] = useState(true);
@ -30,15 +38,19 @@ export const ProtectedRoute = ({ fallback, shouldRedirectToCurrentPage }: Protec
status = "should-land-user";
} else if (!user) {
status = "should-redirect";
} else if (onlyRegularUser && user.is_temporary) {
status = "should-redirect";
} else {
status = "should-pass";
}
const fallbackUrl = fallback ?? "/login";
const redirectUrl = shouldRedirectToCurrentPage
? `${fallback ?? "/login"}?redirect=${encodeURIComponent(
? `${fallbackUrl}?redirect=${encodeURIComponent(
`${window.location.pathname}${window.location.search}`
)}`
: (fallback ?? "/login");
: fallbackUrl;
return match(status)
.with("loading", () => <LoadingSpinner />)

View file

@ -0,0 +1,176 @@
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { Calendar, Clock, Plus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useEventsByTablo } from "../hooks/events";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
interface TabloEventsSectionProps {
tablo: UserTablo;
isAdmin: boolean;
}
export const TabloEventsSection = ({ tablo }: TabloEventsSectionProps) => {
const navigate = useNavigate();
const { data: events, isLoading, error } = useEventsByTablo(tablo.id);
const isReadOnly = useIsReadOnlyUser();
// Filter upcoming events (events in the future or today)
const today = new Date();
today.setHours(0, 0, 0, 0);
const upcomingEvents = events
?.filter((event) => {
const eventDate = new Date(event.start_date);
return eventDate >= today;
})
.sort((a, b) => {
const dateCompare = new Date(a.start_date).getTime() - new Date(b.start_date).getTime();
if (dateCompare !== 0) return dateCompare;
return (a.start_time || "").localeCompare(b.start_time || "");
});
const handleCreateEvent = () => {
navigate(`/events/create?tablo_id=${tablo.id}`);
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return new Intl.DateTimeFormat("fr-FR", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
};
const formatTime = (timeStr: string) => {
if (!timeStr) return "";
return timeStr.slice(0, 5); // HH:MM
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Événements à venir</h1>
<p className="text-muted-foreground mt-1">Gérez les événements futurs de ce tablo</p>
</div>
{!isReadOnly && (
<Button onClick={handleCreateEvent} className="flex items-center gap-2">
<Plus className="w-4 h-4" />
Créer un événement
</Button>
)}
</div>
{/* Events List */}
<div className="bg-card rounded-lg border border-border">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">
Chargement des événements...
</span>
</div>
) : error ? (
<div className="p-6">
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center space-x-2">
<svg
className="w-5 h-5 text-red-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-sm text-red-700 dark:text-red-300">
Erreur lors du chargement des événements
</span>
</div>
</div>
</div>
) : upcomingEvents && upcomingEvents.length > 0 ? (
<div className="divide-y divide-border">
{upcomingEvents.map((event) => (
<div key={event.event_id} className="p-6">
<div className="flex items-start space-x-4">
{/* Date Badge */}
<div className="shrink-0 w-16 h-16 bg-primary/10 dark:bg-primary/20 rounded-lg flex flex-col items-center justify-center">
<span className="text-xs font-medium text-primary uppercase">
{new Date(event.start_date).toLocaleDateString("fr-FR", {
month: "short",
})}
</span>
<span className="text-2xl font-bold text-primary">
{new Date(event.start_date).getDate()}
</span>
</div>
{/* Event Details */}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-1">{event.title}</h3>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
<span className="capitalize">{formatDate(event.start_date)}</span>
</div>
{event.start_time && (
<div className="flex items-center gap-1">
<Clock className="w-4 h-4" />
<span>
{formatTime(event.start_time)}
{event.end_time && ` - ${formatTime(event.end_time)}`}
</span>
</div>
)}
</div>
{event.description && (
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
{event.description}
</p>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<svg
className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="text-sm text-gray-500 dark:text-gray-400">
Aucun événement à venir pour ce tablo
</p>
{!isReadOnly && (
<Button onClick={handleCreateEvent} variant="outline" className="mt-4">
<Plus className="w-4 h-4 mr-2" />
Créer le premier événement
</Button>
)}
</div>
)}
</div>
</div>
);
};

View file

@ -2,14 +2,14 @@ import { toast } from "@xtablo/shared";
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { DownloadIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { FileTrigger } from "react-aria-components";
import { useRef, useState } from "react";
import {
useCreateTabloFile,
useDeleteTabloFile,
useDownloadTabloFile,
useTabloFileNames,
} from "../hooks/tablo_data";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
interface TabloFilesSectionProps {
tablo: UserTablo;
@ -28,13 +28,15 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
const [deletingFile, setDeletingFile] = useState<string | null>(null);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
const [error, setError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const createFile = useCreateTabloFile();
const deleteFile = useDeleteTabloFile();
const downloadFile = useDownloadTabloFile();
const isReadOnly = useIsReadOnlyUser();
const handleFileSelect = (files: FileList | null) => {
const file = files?.[0];
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file size (20MB limit)
@ -70,6 +72,9 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
// Reset upload state
setSelectedFile(null);
setIsUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
} catch (uploadError) {
setIsUploading(false);
console.error("Upload error:", uploadError);
@ -104,6 +109,9 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
const cancelFileUpload = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleDeleteFile = async (fileName: string) => {
@ -183,8 +191,8 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
</div>
)}
{/* File Upload Section - Only for Admins */}
{isAdmin && (
{/* File Upload Section - Only for Admins and non-read-only users */}
{isAdmin && !isReadOnly && (
<div className="bg-card rounded-lg border border-border p-6">
<div className="flex items-center space-x-2 mb-4">
<svg
@ -205,33 +213,39 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
{!selectedFile ? (
<div className="space-y-3">
<FileTrigger allowsMultiple={false} onSelect={handleFileSelect}>
<Button
variant="outline"
className="w-full justify-center py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 bg-gray-50 dark:bg-gray-800/50 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
>
<div className="flex flex-col items-center space-y-2">
<svg
className="w-8 h-8 text-gray-400 dark:text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<div className="text-center">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Cliquez pour sélectionner un fichier
</span>
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFileSelect}
className="hidden"
accept="*/*"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full justify-center py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 bg-gray-50 dark:bg-gray-800/50 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors rounded-md cursor-pointer inline-flex items-center"
>
<div className="flex flex-col items-center space-y-2">
<svg
className="w-8 h-8 text-gray-400 dark:text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<div className="text-center">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Cliquez pour sélectionner un fichier
</span>
</div>
</Button>
</FileTrigger>
</div>
</button>
</div>
) : (
<div className="space-y-3">
@ -444,7 +458,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
<DownloadIcon className="w-4 h-4" />
)}
</Button>
{isAdmin && (
{isAdmin && !isReadOnly && (
<Button
size="sm"
variant="ghost"
@ -487,7 +501,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
/>
</svg>
<p className="text-sm text-gray-500 dark:text-gray-400">Aucun fichier dans ce tablo</p>
{isAdmin && (
{isAdmin && !isReadOnly && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Ajoutez votre premier fichier ci-dessus
</p>

View file

@ -88,6 +88,7 @@ export const routes: RouteObject[] = [
{
path: "events",
element: <EventsPage />,
children: [{ index: true }, { path: "create", element: <EventModal mode="create" /> }],
},
{
path: "feedback",

View file

@ -24,12 +24,13 @@ import {
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Outlet, useNavigate } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { EventTypeCard } from "../components/EventTypeCard";
import { EventTypeConfig, useEventTypes } from "../hooks/event-types";
import { useEventsByTablo } from "../hooks/events";
import { useGetAllTabloAccess, useTablosList } from "../hooks/tablos";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
type BookingStatus = "all" | "upcoming" | "past";
@ -40,6 +41,8 @@ interface BookingStatusOption {
export function EventsPage() {
const navigate = useNavigate();
const { t } = useTranslation(["pages", "common"]);
const isReadOnly = useIsReadOnlyUser();
const [activeTab, setActiveTab] = useState<"events" | "event-types">("events");
const statusOptions: BookingStatusOption[] = [
{ id: "upcoming", name: t("pages:events.filters.upcoming") },
@ -137,6 +140,18 @@ export function EventsPage() {
// Event Types handlers
const handleCreateEventType = () => {
if (isReadOnly) {
toast.add(
{
title: t("common:error"),
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer de type d'événement.",
type: "error",
},
{ timeout: 5000 }
);
return;
}
setEditingEventType(null);
setEventTypeFormData({
name: "",
@ -238,10 +253,21 @@ export function EventsPage() {
};
const handleCreateEvent = () => {
if (isReadOnly) {
toast.add(
{
title: t("common:error"),
description: "Vous êtes en mode lecture seule. Vous ne pouvez pas créer d'événement.",
type: "error",
},
{ timeout: 5000 }
);
return;
}
const today = new Date();
const dateParam = today.toISOString();
const tabloParam = selectedTabloId !== "all" ? `&tablo_id=${selectedTabloId}` : "";
navigate(`/planning/create?date=${dateParam}${tabloParam}`);
navigate(`/events/create?date=${dateParam}${tabloParam}`);
};
const canEditEvent = (event: EventAndTablo) => {
@ -275,21 +301,32 @@ export function EventsPage() {
{/* Main Content with Tabs */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Tabs defaultValue="events" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="events">{t("pages:events.tabs.events")}</TabsTrigger>
<TabsTrigger value="event-types">{t("pages:events.tabs.eventTypes")}</TabsTrigger>
</TabsList>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as "events" | "event-types")}
className="w-full"
>
<div className="flex items-center justify-between mb-6">
<TabsList>
<TabsTrigger value="events">{t("pages:events.tabs.events")}</TabsTrigger>
<TabsTrigger value="event-types">{t("pages:events.tabs.eventTypes")}</TabsTrigger>
</TabsList>
{/* Events Tab */}
<TabsContent value="events" className="space-y-6">
<div className="flex items-center justify-end mb-4">
<Button onClick={handleCreateEvent}>
{activeTab === "events" ? (
<Button onClick={handleCreateEvent} disabled={isReadOnly}>
<CalendarIcon className="w-4 h-4 mr-2" />
{t("pages:events.createEvent")}
</Button>
</div>
) : (
<Button onClick={handleCreateEventType} disabled={isReadOnly}>
<PlusIcon className="w-4 h-4 mr-2" />
{t("pages:events.createEventType")}
</Button>
)}
</div>
{/* Events Tab */}
<TabsContent value="events" className="space-y-6">
{/* Filters */}
<div className="bg-card rounded-lg shadow-sm border border-border p-6">
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center">
@ -573,12 +610,6 @@ export function EventsPage() {
{/* Event Types Tab */}
<TabsContent value="event-types" className="space-y-6">
<div className="flex items-center justify-end mb-4">
<Button size="lg" variant="default" onClick={handleCreateEventType}>
<PlusIcon className="w-4 h-4 mr-2" /> {t("pages:events.createEventType")}
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{eventTypesData?.map((eventType) => (
<EventTypeCard
@ -594,7 +625,12 @@ export function EventsPage() {
<Text className="text-muted-foreground mb-4">
{t("pages:events.eventTypes.emptyState.title")}
</Text>
<Button variant="default" size="lg" onClick={handleCreateEventType}>
<Button
variant="default"
size="lg"
onClick={handleCreateEventType}
disabled={isReadOnly}
>
<PlusIcon className="w-4 h-4 mr-2" />{" "}
{t("pages:events.eventTypes.emptyState.button")}
</Button>
@ -625,6 +661,9 @@ export function EventsPage() {
handleSaveEventType={handleSaveEventType}
/>
</main>
{/* Render child routes (e.g. EventModal) */}
<Outlet />
</div>
);
}

View file

@ -40,6 +40,7 @@ import {
useUpdateNote,
useUpdateNoteSharing,
} from "../hooks/notes";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
const useNoteId = () => {
const navigate = useNavigate();
@ -62,6 +63,7 @@ const useNoteId = () => {
export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
const { t } = useTranslation(["notes", "common"]);
const { noteId, setNoteId, goCreateNote } = useNoteId();
const isReadOnly = useIsReadOnlyUser();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
@ -123,6 +125,15 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
};
const handleSave = () => {
if (isReadOnly) {
toast.add({
title: t("common:error", "Error"),
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer ou modifier de notes.",
type: "error",
});
return;
}
if (!content && !title) {
toast.add({
title: t("common:error", "Error"),
@ -163,6 +174,14 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
};
const handleNewNote = () => {
if (isReadOnly) {
toast.add({
title: t("common:error", "Error"),
description: "Vous êtes en mode lecture seule. Vous ne pouvez pas créer de notes.",
type: "error",
});
return;
}
if (hasUnsavedChanges) {
const confirmed = window.confirm(t("unsavedChangesWarning"));
if (!confirmed) return;
@ -241,7 +260,12 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
)}
</TypographyMuted>
</div>
<Button variant="outline" onClick={handleNewNote} className="mt-2">
<Button
variant="outline"
onClick={handleNewNote}
className="mt-2"
disabled={isReadOnly}
>
<PlusIcon className="mr-2 size-4" />
{t("createNewNote", "Create a new note")}
</Button>
@ -262,6 +286,7 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
setTitle(e.target.value);
setHasUnsavedChanges(true);
}}
disabled={isReadOnly}
className="border-0 text-2xl font-bold focus-visible:ring-0"
/>
{noteId && (
@ -276,7 +301,7 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
},
});
}}
disabled={isDeleting}
disabled={isDeleting || isReadOnly}
title={t("deleteNote")}
className="hover:text-red-500 hover:bg-red-50/10 dark:hover:bg-red-950/10"
>
@ -290,6 +315,7 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
key={editorKey}
initialContent={note?.content ?? content}
onChange={handleContentChange}
readOnly={isReadOnly}
/>
</CardContent>
</Card>
@ -406,7 +432,10 @@ export default function NotesPage({ mode }: { mode: "create" | "edit" }) {
</DialogContent>
</Dialog>
)}
<Button onClick={handleSave} disabled={!hasUnsavedChanges || isCreating || isUpdating}>
<Button
onClick={handleSave}
disabled={!hasUnsavedChanges || isCreating || isUpdating || isReadOnly}
>
<SaveIcon className="mr-2 size-4" />
{isCreating ? t("saving") : t("saveNote")}
</Button>

View file

@ -1,6 +1,6 @@
import { ImportICSModal } from "@ui/components/ImportICSModal";
import { WebcalModal } from "@ui/components/WebcalModal";
import { downloadICSFile, generateICSFromEvents } from "@xtablo/shared";
import { downloadICSFile, generateICSFromEvents, toast } from "@xtablo/shared";
import { EventAndTablo } from "@xtablo/shared/types/events.types";
import { Button } from "@xtablo/ui/components/button";
import {
@ -17,6 +17,7 @@ import { useTranslation } from "react-i18next";
import { Outlet, useNavigate, useParams } from "react-router-dom";
import { useDeleteEvent, useEventsByTablo } from "../hooks/events";
import { useGetAllTabloAccess, useTablosList } from "../hooks/tablos";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
type ViewType = "month" | "week" | "day";
@ -30,6 +31,7 @@ export const PlanningPage = () => {
const [selectedTabloId, setSelectedTabloId] = useState<string>(tablo_id || "all");
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isWebcalModalOpen, setIsWebcalModalOpen] = useState(false);
const isReadOnly = useIsReadOnlyUser();
// Fetch tablos
const { data: tablos, isLoading: tablosLoading } = useTablosList();
@ -697,6 +699,18 @@ export const PlanningPage = () => {
<Button
onClick={() => {
if (isReadOnly) {
toast.add(
{
title: t("common:error"),
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer d'événement.",
type: "error",
},
{ timeout: 5000 }
);
return;
}
if (selectedTabloId === "all") {
navigate(`/planning/create?date=${currentDate.toISOString()}`);
} else {
@ -706,15 +720,31 @@ export const PlanningPage = () => {
}
}}
className="w-full"
disabled={isReadOnly}
>
<PlusIcon className="w-5 h-5 mr-2" />
{t("planning:createEvent")}
</Button>
<Button
onClick={() => setIsImportModalOpen(true)}
onClick={() => {
if (isReadOnly) {
toast.add(
{
title: t("common:error"),
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas importer de calendrier.",
type: "error",
},
{ timeout: 5000 }
);
return;
}
setIsImportModalOpen(true);
}}
variant="secondary"
className="w-full mt-2"
disabled={isReadOnly}
>
<FolderInputIcon className="w-5 h-5 mr-2" />
{t("planning:importPlanning")}

View file

@ -1,17 +1,18 @@
import { toast } from "@xtablo/shared";
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { ArrowLeft, BookOpen, FileText, MessageSquare, Settings } from "lucide-react";
import { ArrowLeft, BookOpen, Calendar, FileText, MessageSquare, Settings } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { LoadingSpinner } from "../components/LoadingSpinner";
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
import { TabloEventsSection } from "../components/TabloEventsSection";
import { TabloFilesSection } from "../components/TabloFilesSection";
import { TabloNotesSection } from "../components/TabloNotesSection";
import { TabloSettingsSection } from "../components/TabloSettingsSection";
import { useTablosList, useUpdateTablo } from "../hooks/tablos";
type TabSection = "files" | "discussion" | "notes" | "settings";
type TabSection = "files" | "discussion" | "notes" | "events" | "settings";
export const TabloDetailsPage = () => {
const { tabloId } = useParams<{ tabloId: string }>();
@ -99,6 +100,11 @@ export const TabloDetailsPage = () => {
label: "Notes",
icon: <BookOpen className="w-5 h-5" />,
},
{
id: "events",
label: "Événements",
icon: <Calendar className="w-5 h-5" />,
},
{
id: "settings",
label: "Paramètres",
@ -177,6 +183,7 @@ export const TabloDetailsPage = () => {
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />
)}
{activeSection === "notes" && <TabloNotesSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "events" && <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "settings" && (
<TabloSettingsSection tablo={tablo} isAdmin={isAdmin} onEdit={handleEdit} />
)}

View file

@ -1,6 +1,7 @@
import { CreateTabloModal } from "@ui/components/CreateTabloModal";
import { DeleteTabloModal } from "@ui/components/DeleteTabloModal";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { toast } from "@xtablo/shared";
import { TabloInsert, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import {
@ -35,6 +36,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useCreateTablo, useDeleteTablo, useTablosList, useUpdateTablo } from "../hooks/tablos";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
type FilterOption = {
id: "all" | "todo" | "inProgress" | "done";
@ -54,6 +56,7 @@ export const TabloPage = () => {
const [filterType, setFilterType] = useState<"all" | "todo" | "inProgress" | "done">("all");
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const isReadOnly = useIsReadOnlyUser();
// Get view mode from URL params, default to "list"
const viewMode = (searchParams.get("view") as "grid" | "list") || "list";
@ -106,6 +109,17 @@ export const TabloPage = () => {
];
const openCreateModal = () => {
if (isReadOnly) {
toast.add(
{
title: t("common:error"),
description: "Vous êtes en mode lecture seule. Vous ne pouvez pas créer de tablo.",
type: "error",
},
{ timeout: 5000 }
);
return;
}
setIsCreateModalOpen(true);
};
@ -238,7 +252,7 @@ export const TabloPage = () => {
Gérez vos projets et collaborations
</Text>
</div>
<Button onClick={openCreateModal}>
<Button onClick={openCreateModal} disabled={isReadOnly}>
<Plus /> Nouveau tablo
</Button>
</div>
@ -266,7 +280,7 @@ export const TabloPage = () => {
Gérez vos projets et collaborations
</Text>
</div>
<Button onClick={openCreateModal}>
<Button onClick={openCreateModal} disabled={isReadOnly}>
<Plus /> Nouveau tablo
</Button>
</div>
@ -693,7 +707,7 @@ export const TabloPage = () => {
<Button
id="create-tablo-button"
onClick={openCreateModal}
disabled={createTabloMutation.isPending}
disabled={createTabloMutation.isPending || isReadOnly}
>
<Plus />
{createTabloMutation.isPending
@ -706,7 +720,7 @@ export const TabloPage = () => {
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* KPI Section */}
{kpis && (
{kpis && !isReadOnly && (
<div className="mb-8">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4">
{/* Total Tablos */}
@ -842,7 +856,12 @@ export const TabloPage = () => {
</EmptyHeader>
{filterType === "all" && (
<EmptyContent>
<Button variant="default" size="lg" onClick={openCreateModal}>
<Button
variant="default"
size="lg"
onClick={openCreateModal}
disabled={isReadOnly}
>
<Plus /> {t("pages:tablo.emptyState.button")}
</Button>
</EmptyContent>

View file

@ -66,6 +66,14 @@ export const useMaybeUser = () => {
return useStore(store);
};
export const useIsReadOnlyUser = () => {
const store = React.useContext(UserStoreContext);
if (!store) {
throw new Error("Missing UserStoreProvider");
}
return useStore(store).is_temporary;
};
// TestUserStoreProvider component
export const TestUserStoreProvider = ({
children,

View file

@ -21,11 +21,18 @@ CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes(created_at);
-- Enable Row Level Security
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
-- Policy to allow users to view their own notes
CREATE POLICY "Users can view their own notes" ON notes
-- Policy to allow users to view their own notes and public notes
CREATE POLICY "Users can view their own notes and public notes" ON notes
FOR SELECT
TO authenticated
USING (user_id = (SELECT auth.uid()));
TO authenticated, anon
USING (
user_id = (SELECT auth.uid())
OR EXISTS (
SELECT 1 FROM shared_notes
WHERE shared_notes.note_id = notes.id
AND shared_notes.is_public = TRUE
)
);
-- Policy to allow users to insert their own notes
CREATE POLICY "Users can insert their own notes" ON notes
@ -62,7 +69,7 @@ CREATE POLICY "Users can delete their own notes" ON notes
-- Add comments to document the table
COMMENT ON TABLE notes IS
'User notes with Row Level Security to ensure users can only access their own notes';
'User notes with Row Level Security. Users can access their own notes and public notes (marked in shared_notes table)';
COMMENT ON COLUMN notes.id IS
'Primary key: random 24-character alphanumeric string';

View file

@ -31,7 +31,7 @@ CREATE POLICY "Users can view their own shared notes" ON shared_notes
-- Policy to allow anonymous users to view public notes
CREATE POLICY "Anyone can view public notes" ON shared_notes
FOR SELECT
TO anon
TO anon, authenticated
USING (is_public = TRUE);
-- Policy to allow users to insert their own shared_notes entries