diff --git a/app/api/agenda/ical/route.ts b/app/api/agenda/ical/route.ts new file mode 100644 index 0000000..a9e33e7 --- /dev/null +++ b/app/api/agenda/ical/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server" + +const MAX_ICS_BYTES = 2 * 1024 * 1024 + +function isAllowedIcsUrl(raw: string): boolean { + try { + const url = new URL(raw) + if (url.protocol !== "http:" && url.protocol !== "https:") return false + const host = url.hostname.toLowerCase() + if (host === "localhost" || host === "127.0.0.1" || host === "::1") { + return false + } + return true + } catch { + return false + } +} + +export async function GET(request: Request) { + const url = new URL(request.url).searchParams.get("url")?.trim() + if (!url || !isAllowedIcsUrl(url)) { + return NextResponse.json({ error: "invalid_url" }, { status: 400 }) + } + + try { + const res = await fetch(url, { + headers: { Accept: "text/calendar, text/plain, */*" }, + redirect: "follow", + cache: "no-store", + }) + if (!res.ok) { + return NextResponse.json({ error: "fetch_failed" }, { status: 502 }) + } + + const contentType = res.headers.get("content-type") ?? "" + if ( + contentType && + !contentType.includes("text/calendar") && + !contentType.includes("text/plain") && + !contentType.includes("application/octet-stream") + ) { + return NextResponse.json({ error: "unsupported_content_type" }, { status: 415 }) + } + + const buffer = await res.arrayBuffer() + if (buffer.byteLength > MAX_ICS_BYTES) { + return NextResponse.json({ error: "payload_too_large" }, { status: 413 }) + } + + return new NextResponse(new TextDecoder("utf-8").decode(buffer), { + status: 200, + headers: { + "Content-Type": "text/calendar; charset=utf-8", + "Cache-Control": "private, max-age=300", + }, + }) + } catch { + return NextResponse.json({ error: "fetch_failed" }, { status: 502 }) + } +} diff --git a/app/apple-icon.png b/app/apple-icon.png index c059766..614f354 100644 Binary files a/app/apple-icon.png and b/app/apple-icon.png differ diff --git a/app/compte/layout.tsx b/app/compte/layout.tsx index b9ee6e4..644e290 100644 --- a/app/compte/layout.tsx +++ b/app/compte/layout.tsx @@ -1,4 +1,5 @@ import { CompteSettingsLayout } from "@/components/compte/compte-settings-layout" +import { SuiteThemeShell } from "@/components/suite/suite-theme-shell" import type { Metadata } from "next" import { suitePageMetadata } from "@/lib/suite/page-metadata" @@ -11,5 +12,9 @@ export default function CompteRootLayout({ }: { children: React.ReactNode }) { - return {children} + return ( + + {children} + + ) } diff --git a/app/demo/agenda/[[...segments]]/page.tsx b/app/demo/agenda/[[...segments]]/page.tsx new file mode 100644 index 0000000..0ac158e --- /dev/null +++ b/app/demo/agenda/[[...segments]]/page.tsx @@ -0,0 +1,12 @@ +"use client" + +import { Suspense } from "react" +import { AgendaPage } from "@/components/agenda/agenda-page" + +export default function DemoAgendaRoutePage() { + return ( + + + + ) +} diff --git a/app/demo/agenda/layout.tsx b/app/demo/agenda/layout.tsx new file mode 100644 index 0000000..6b366f4 --- /dev/null +++ b/app/demo/agenda/layout.tsx @@ -0,0 +1,22 @@ +import { DemoAgendaShell } from "@/components/demo/demo-agenda-shell" +import type { Metadata } from "next" +import { suitePageMetadata } from "@/lib/suite/page-metadata" + +export const metadata: Metadata = { + ...suitePageMetadata({ + app: "agenda", + title: "Démo UltiAgenda", + absoluteTitle: true, + description: + "Essayez l'agenda UltiAgenda sans compte — démo interactive, zéro rétention.", + }), + robots: { index: false }, +} + +export default function DemoAgendaLayout({ + children, +}: { + children: React.ReactNode +}) { + return {children} +} diff --git a/app/demo/contacts/layout.tsx b/app/demo/contacts/layout.tsx new file mode 100644 index 0000000..f0560f2 --- /dev/null +++ b/app/demo/contacts/layout.tsx @@ -0,0 +1,22 @@ +import { DemoContactsShell } from "@/components/demo/demo-contacts-shell" +import type { Metadata } from "next" +import { suitePageMetadata } from "@/lib/suite/page-metadata" + +export const metadata: Metadata = { + ...suitePageMetadata({ + app: "contacts", + title: "Démo Contacts", + absoluteTitle: true, + description: + "Essayez les contacts Ulti Suite sans compte — démo interactive, zéro rétention.", + }), + robots: { index: false }, +} + +export default function DemoContactsLayout({ + children, +}: { + children: React.ReactNode +}) { + return {children} +} diff --git a/app/demo/contacts/page.tsx b/app/demo/contacts/page.tsx new file mode 100644 index 0000000..e5cdc3b --- /dev/null +++ b/app/demo/contacts/page.tsx @@ -0,0 +1,4 @@ +/** Route racine : l'interface contacts est rendue par `app/demo/contacts/layout.tsx`. */ +export default function DemoContactsPage() { + return null +} diff --git a/app/demo/drive/[[...segments]]/page.tsx b/app/demo/drive/[[...segments]]/page.tsx new file mode 100644 index 0000000..4d6ac8d --- /dev/null +++ b/app/demo/drive/[[...segments]]/page.tsx @@ -0,0 +1 @@ +export { default } from "@/app/drive/(browser)/[[...segments]]/page" diff --git a/app/demo/drive/layout.tsx b/app/demo/drive/layout.tsx new file mode 100644 index 0000000..1afb423 --- /dev/null +++ b/app/demo/drive/layout.tsx @@ -0,0 +1,28 @@ +import { DriveRouteScope } from "@/components/drive/drive-route-scope" +import { DemoDriveShell } from "@/components/demo/demo-drive-shell" +import type { Metadata } from "next" +import { suitePageMetadata } from "@/lib/suite/page-metadata" + +export const metadata: Metadata = { + ...suitePageMetadata({ + app: "drive", + title: "Démo UltiDrive", + absoluteTitle: true, + description: + "Essayez le drive UltiDrive sans compte — démo interactive, zéro rétention.", + }), + robots: { index: false }, +} + +export default function DemoDriveLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <> + + {children} + + ) +} diff --git a/app/demo/mail/[[...segments]]/page.tsx b/app/demo/mail/[[...segments]]/page.tsx new file mode 100644 index 0000000..cbfd623 --- /dev/null +++ b/app/demo/mail/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +/** Route catch-all : l'interface mail réelle est rendue par `app/demo/mail/layout.tsx`. */ +export default function DemoMailSegmentsPage() { + return null +} diff --git a/app/demo/mail/page.tsx b/app/demo/mail/layout.tsx similarity index 64% rename from app/demo/mail/page.tsx rename to app/demo/mail/layout.tsx index 00dc420..38bc817 100644 --- a/app/demo/mail/page.tsx +++ b/app/demo/mail/layout.tsx @@ -1,5 +1,5 @@ +import { DemoMailShell } from "@/components/demo/demo-mail-shell" import type { Metadata } from "next" -import { DemoMailApp } from "@/components/demo/demo-mail-app" import { suitePageMetadata } from "@/lib/suite/page-metadata" export const metadata: Metadata = { @@ -13,6 +13,10 @@ export const metadata: Metadata = { robots: { index: false }, } -export default function DemoMailPage() { - return +export default function DemoMailLayout({ + children, +}: { + children: React.ReactNode +}) { + return {children} } diff --git a/app/drive/(browser)/[[...segments]]/page.tsx b/app/drive/(browser)/[[...segments]]/page.tsx index cb73ee2..5cdebbc 100644 --- a/app/drive/(browser)/[[...segments]]/page.tsx +++ b/app/drive/(browser)/[[...segments]]/page.tsx @@ -27,12 +27,15 @@ import { import { cn } from "@/lib/utils" import { useDriveList, + useDriveMountList, + useDriveOrgList, useDriveRecent, useDriveSearch, useDriveSharedWithMe, useDriveStarred, useDriveTrash, } from "@/lib/api/hooks/use-drive-queries" +import { pathRefFromRoute } from "@/lib/api/drive-roots" export default function DriveBrowserPage() { const params = useParams() @@ -42,7 +45,13 @@ export default function DriveBrowserPage() { const folderPath = folderPathFromSegments(route.pathSegments) const contextView = - route.view === "shared" ? "shared" : route.view === "search" ? "files" : route.view + route.view === "shared" + ? "shared" + : route.view === "search" + ? "files" + : route.view === "org" || route.view === "mount" + ? route.view + : route.view const fallbackScope = defaultDriveSearchScope( route.view === "shared" ? "shared" : "files", folderPath @@ -78,6 +87,8 @@ export default function DriveBrowserPage() { const folderPlacement = useDriveSettingsStore((s) => s.folderPlacement) const list = useDriveList(folderPath, route.page, "", route.view === "files") + const orgList = useDriveOrgList(route.rootId ?? "", folderPath, route.page, route.view === "org" && Boolean(route.rootId)) + const mountList = useDriveMountList(route.rootId ?? "", folderPath, route.page, route.view === "mount" && Boolean(route.rootId)) const shared = useDriveSharedWithMe( route.page, "", @@ -113,7 +124,11 @@ export default function DriveBrowserPage() { ? route.pathSegments.length === 0 ? shared : sharedFolder - : list + : route.view === "org" + ? orgList + : route.view === "mount" + ? mountList + : list const files = active.data?.files ?? [] @@ -186,6 +201,7 @@ export default function DriveBrowserPage() { 0 ? ( ) : null} diff --git a/app/drive/mounts/oauth/callback/page.tsx b/app/drive/mounts/oauth/callback/page.tsx new file mode 100644 index 0000000..833c35a --- /dev/null +++ b/app/drive/mounts/oauth/callback/page.tsx @@ -0,0 +1,57 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useSearchParams } from "next/navigation" +import { useDriveMountMutations } from "@/lib/api/hooks/use-drive-queries" +import { Button } from "@/components/ui/button" +import { buildDriveMountOAuthRedirectURI } from "@/lib/drive/drive-mount-oauth" + +function DriveMountOAuthCallbackInner() { + const searchParams = useSearchParams() + const { completeOAuth } = useDriveMountMutations() + const [message, setMessage] = useState("Finalisation de la connexion…") + const [done, setDone] = useState(false) + + useEffect(() => { + const code = searchParams.get("code") + const mountId = searchParams.get("state") ?? searchParams.get("mount_id") + if (!code || !mountId) { + setMessage("Paramètres OAuth manquants.") + setDone(true) + return + } + void completeOAuth + .mutateAsync({ mountId, code, redirectUri: buildDriveMountOAuthRedirectURI() }) + .then(() => { + setMessage("Volume connecté avec succès.") + setDone(true) + if (window.opener) { + window.opener.postMessage({ type: "drive-mount-oauth-complete", mountId }, window.location.origin) + window.setTimeout(() => window.close(), 800) + } + }) + .catch(() => { + setMessage("Échec de la connexion OAuth. Réessayez depuis UltiDrive.") + setDone(true) + }) + }, [completeOAuth, searchParams]) + + return ( +
+

{message}

+ {done && !window.opener ? ( + + ) : null} +
+ ) +} + +export default function DriveMountOAuthCallbackPage() { + return ( + Chargement…}> + + + ) +} diff --git a/app/globals.css b/app/globals.css index 0bdba42..63536f4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -158,8 +158,8 @@ } @theme inline { - --font-sans: 'Geist', 'Geist Fallback'; - --font-mono: 'Geist Mono', 'Geist Mono Fallback'; + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -573,6 +573,14 @@ html[data-splash-seen='1'] .app-first-launch-splash { animation: splash-logo-float 2s ease-in-out infinite; } +.app-first-launch-splash__mark { + transform-origin: center center; +} + +.app-first-launch-splash__mark--spin { + animation: splash-logo-spin 0.72s linear infinite; +} + .app-first-launch-splash__loader { width: min(58vw, 230px); height: 4px; @@ -595,6 +603,7 @@ html[data-splash-seen='1'] .app-first-launch-splash { .app-first-launch-splash__grain, .app-first-launch-splash__content, .app-first-launch-splash__logo, + .app-first-launch-splash__mark--spin, .app-first-launch-splash__loader > span { animation: none !important; } @@ -609,7 +618,7 @@ html:has(.ultimail-login) body { background-color: transparent !important; } -/* ── Drive : pas de fond décoratif mail ni splash Ultimail (y compris chargement) ── */ +/* ── Drive : pas de fond décoratif mail ── */ html[data-route-scope='drive']::before, html:has([data-drive-app])::before { opacity: 0 !important; @@ -623,12 +632,6 @@ html[data-route-scope='drive'] body { background-color: var(--app-canvas) !important; } -html[data-route-scope='drive'] .app-first-launch-splash { - opacity: 0; - visibility: hidden; - pointer-events: none; -} - @media (min-width: 640px) { .ultimail-login-card-frame { padding: 3px; @@ -727,9 +730,31 @@ html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app background-color: var(--mail-surface-muted) !important; } -/* Réglages / administration : fond décoratif visible uniquement derrière la sidebar (contenu opaque). */ +/* Agenda : pas de fond décoratif mail — surfaces opaques (carte arrondie + chrome). */ +html[data-mail-background]:not([data-mail-background='none']) [data-agenda-app].ultimail-app { + background-color: var(--app-canvas) !important; +} + +html[data-mail-background]:not([data-mail-background='none']) [data-agenda-app] :where(.bg-app-canvas) { + background-color: var(--app-canvas) !important; +} + +html[data-mail-background]:not([data-mail-background='none']) [data-agenda-app] :where(.bg-mail-surface, .bg-white) { + background-color: var(--mail-surface) !important; +} + +html[data-mail-background]:not([data-mail-background='none']) [data-agenda-app] :where(.bg-mail-surface-elevated) { + background-color: var(--mail-surface-elevated) !important; +} + +html[data-mail-background]:not([data-mail-background='none']) [data-agenda-app] [data-agenda-calendar-card] { + background-color: var(--mail-surface) !important; +} + +/* Réglages / administration / compte : fond décoratif visible uniquement derrière la sidebar (contenu opaque). */ html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-app].ultimail-app, -html[data-mail-background]:not([data-mail-background='none']) [data-admin-settings-app].ultimail-app { +html[data-mail-background]:not([data-mail-background='none']) [data-admin-settings-app].ultimail-app, +html[data-mail-background]:not([data-mail-background='none']) [data-compte-settings-app].ultimail-app { background-color: var(--app-canvas) !important; } @@ -738,7 +763,10 @@ html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-sidebar], html[data-mail-background]:not([data-mail-background='none']) [data-admin-settings-app] - [data-admin-settings-sidebar] { + [data-admin-settings-sidebar], +html[data-mail-background]:not([data-mail-background='none']) + [data-compte-settings-app] + [data-compte-settings-sidebar] { background-color: color-mix(in srgb, var(--app-canvas) 72%, transparent) !important; } @@ -747,7 +775,10 @@ html[data-mail-background]:not([data-mail-background='none']) :where([data-mail-settings-main]), html[data-mail-background]:not([data-mail-background='none']) [data-admin-settings-app] - :where([data-admin-settings-main]) { + :where([data-admin-settings-main]), +html[data-mail-background]:not([data-mail-background='none']) + [data-compte-settings-app] + :where([data-compte-settings-main]) { background-color: var(--mail-surface) !important; } @@ -1239,8 +1270,9 @@ html.dark :where([data-contacts-panel] .border-gray-200, [data-contacts-panel] . border-color: var(--border) !important; } -/* Réglages mail : cartes cohérentes en dark mode (fond + bordure plus visible) */ -html.dark [data-mail-settings-main] { +/* Réglages mail / compte : cartes cohérentes en dark mode (fond + bordure plus visible) */ +html.dark [data-mail-settings-main], +html.dark [data-compte-settings-main] { --border: var(--mail-border); } @@ -1270,6 +1302,18 @@ html.dark [data-mail-settings-main] :where( border-color: color-mix(in srgb, var(--mail-border) 72%, transparent) !important; } +html.dark [data-compte-settings-main] :where( + .mail-settings-card, + [data-slot='card'], + [class*='rounded-lg'].border, + [class*='rounded-xl'].border, + [class*='rounded-md'].border, + [class*='rounded-2xl'].border +) { + background-color: var(--mail-surface-elevated) !important; + border-color: var(--mail-border) !important; +} + /* Settings / Drive : cartes et champs internes — gris mail, pas le noir shadcn */ html.dark .ultimail-app :where(.bg-background) { background-color: var(--mail-surface-muted) !important; diff --git a/app/icon.png b/app/icon.png index 7242607..8e90ab7 100644 Binary files a/app/icon.png and b/app/icon.png differ diff --git a/app/layout.tsx b/app/layout.tsx index 25181a2..5f4bdfb 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -10,8 +10,8 @@ import { SessionGuard } from '@/components/auth/session-guard' import { MailToaster } from '@/components/gmail/mail-toaster' import { suiteRootMetadata } from '@/lib/suite/page-metadata' -const _geist = Geist({ subsets: ["latin"] }); -const _geistMono = Geist_Mono({ subsets: ["latin"] }); +const geistSans = Geist({ subsets: ['latin'], variable: '--font-geist-sans' }) +const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-geist-mono' }) export const metadata: Metadata = suiteRootMetadata() @@ -30,7 +30,11 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - + diff --git a/app/mail/mail-app-shell.tsx b/app/mail/mail-app-shell.tsx index 62b2786..38fd413 100644 --- a/app/mail/mail-app-shell.tsx +++ b/app/mail/mail-app-shell.tsx @@ -7,6 +7,7 @@ import { useLayoutEffect, useState, } from "react" +import { usePathname } from "next/navigation" import { useIsXs } from "@/hooks/use-xs" import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav" import { useMailSplitView } from "@/hooks/use-mail-split-view" @@ -16,7 +17,6 @@ import { searchParamsToDisplayQuery } from "@/lib/mail-search/search-filter" import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar" import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay" import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome" -import { useRouter, usePathname } from "next/navigation" import { Sidebar } from "@/components/gmail/sidebar" import { Header } from "@/components/gmail/header" import { EmailList } from "@/components/gmail/email-list" @@ -55,7 +55,6 @@ function isMailSettingsPath(pathname: string | null): boolean { } function MailAppInner() { - const router = useRouter() const { route, navigateRoute, searchParams: currentSearchParams } = useMailRoute() const activeSearchQuery = @@ -204,7 +203,14 @@ function MailAppInner() { xsViewChrome={xsViewChrome} onOpenSearch={() => setMobileSearchOpen(true)} searchQuery={activeSearchQuery} - onClearSearch={() => router.push("/mail/inbox")} + onClearSearch={() => + navigateRoute({ + folderId: "inbox", + inboxTab: DEFAULT_INBOX_TAB, + page: 1, + mailId: null, + }) + } /> ) : null} +}) { + const { room } = await params + + return ( + + + Chargement… + + } + > + + + ) +} diff --git a/app/meet/join/page.tsx b/app/meet/join/page.tsx new file mode 100644 index 0000000..43a6ce5 --- /dev/null +++ b/app/meet/join/page.tsx @@ -0,0 +1,18 @@ +import { Suspense } from "react" +import { Loader2 } from "lucide-react" +import { MeetJoinClient } from "@/components/meet/meet-join-client" + +export default function MeetJoinPage() { + return ( + + + Chargement… + + } + > + + + ) +} diff --git a/app/meet/layout.tsx b/app/meet/layout.tsx new file mode 100644 index 0000000..8a002cd --- /dev/null +++ b/app/meet/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react" +import { MeetAppShell } from "@/components/meet/meet-app-shell" +import { suitePageMetadata } from "@/lib/suite/page-metadata" + +export const metadata = suitePageMetadata({ app: "meet" }) + +export default function MeetLayout({ children }: { children: ReactNode }) { + return {children} +} diff --git a/app/meet/page.tsx b/app/meet/page.tsx new file mode 100644 index 0000000..6b44750 --- /dev/null +++ b/app/meet/page.tsx @@ -0,0 +1,5 @@ +import { MeetLobby } from "@/components/meet/meet-lobby" + +export default function MeetPage() { + return +} diff --git a/components/admin/settings/admin-access-guard.tsx b/components/admin/settings/admin-access-guard.tsx index 6ebb100..f85684b 100644 --- a/components/admin/settings/admin-access-guard.tsx +++ b/components/admin/settings/admin-access-guard.tsx @@ -1,16 +1,13 @@ "use client" import Link from "next/link" -import { useAuthStore } from "@/lib/api/auth-store" import { useAuthReady } from "@/lib/api/use-auth-ready" -import { useCurrentUser } from "@/lib/api/hooks/use-current-user" -import { adminScopesFromToken, isPlatformAdminFromToken } from "@/lib/auth/admin" +import { usePlatformAdminAccess } from "@/lib/auth/use-platform-admin-access" import { Button } from "@/components/ui/button" export function AdminAccessGuard({ children }: { children: React.ReactNode }) { const { ready, authenticated } = useAuthReady() - const token = useAuthStore((s) => s.accessToken) - const { data: me, isFetching: meLoading } = useCurrentUser() + const { isAdmin, adminReady } = usePlatformAdminAccess() if (!ready) { return ( @@ -29,16 +26,12 @@ export function AdminAccessGuard({ children }: { children: React.ReactNode }) { ) } - if (meLoading && !me) { + if (!adminReady) { return (

Vérification des droits administrateur…

) } - const scopes = adminScopesFromToken(token) - const isAdmin = - isPlatformAdminFromToken(token) || scopes.read || me?.platform_admin === true - if (!isAdmin) { return (
diff --git a/components/admin/settings/admin-settings-header.tsx b/components/admin/settings/admin-settings-header.tsx index 32bbe3a..4a45fd0 100644 --- a/components/admin/settings/admin-settings-header.tsx +++ b/components/admin/settings/admin-settings-header.tsx @@ -2,6 +2,12 @@ import { AdminLogo } from "@/components/admin/admin-logo" import { HeaderAccountActions } from "@/components/suite/header-account-actions" +import { + SUITE_APP_LOGO_LOCKUP_CLASS, + SUITE_APP_LOGO_MARK_CLASS, + SUITE_APP_LOGO_TEXT_CLASS, +} from "@/lib/suite/suite-chrome-classes" +import { cn } from "@/lib/utils" const SETTINGS_HREF = "/admin/settings" @@ -11,13 +17,18 @@ export function AdminSettingsHeader() { data-admin-settings-chrome-header className="flex h-16 w-full shrink-0 items-center gap-0 bg-app-canvas pr-4 sm:gap-2" > -
- - Administration +
+ + Administration
- +
diff --git a/components/admin/settings/admin-settings-section-view.tsx b/components/admin/settings/admin-settings-section-view.tsx index 0641248..6ffbb89 100644 --- a/components/admin/settings/admin-settings-section-view.tsx +++ b/components/admin/settings/admin-settings-section-view.tsx @@ -21,6 +21,8 @@ import { MailingSection } from "@/components/admin/settings/sections/mailing-sec import { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section" import { RichtextSection } from "@/components/admin/settings/sections/richtext-section" import { AiAssistantSection } from "@/components/admin/settings/sections/ai-assistant-section" +import { AgendaSection } from "@/components/admin/settings/sections/agenda-section" +import { UltimeetSection } from "@/components/admin/settings/sections/ultimeet-section" import { AuditSection } from "@/components/admin/settings/sections/audit-section" const SECTIONS: Record = { @@ -36,6 +38,8 @@ const SECTIONS: Record = { search: SearchSection, plugins: PluginsSection, nextcloud: NextcloudSection, + agenda: AgendaSection, + ultimeet: UltimeetSection, mailing: MailingSection, onlyoffice: OnlyofficeSection, richtext: RichtextSection, diff --git a/components/admin/settings/org-settings-form.tsx b/components/admin/settings/org-settings-form.tsx index a7de5b4..cde745b 100644 --- a/components/admin/settings/org-settings-form.tsx +++ b/components/admin/settings/org-settings-form.tsx @@ -28,9 +28,10 @@ export function OrgSettingsSection({ }) { const [saved, setSaved] = useState(false) const [error, setError] = useState(null) - const { isFetching, isError, refetch } = useOrgSettings() + const { isFetching, isError, refetch, isFetched } = useOrgSettings() const savePolicy = useSaveOrgPolicy() const apiSynced = useOrgSettingsStore((s) => s.apiSynced) + const showPendingBanner = !apiSynced && !isError && (isFetching || !isFetched) const hasSave = Boolean(policySection) async function handleSave() { @@ -51,7 +52,7 @@ export function OrgSettingsSection({ <> refetch()} /> - {!apiSynced ? : null} + {!showPendingBanner ? null : } {showEffectiveBanner ? : null}
{children}
{hasSave ? ( diff --git a/components/admin/settings/org-settings-sync.tsx b/components/admin/settings/org-settings-sync.tsx index 360fac5..3d46492 100644 --- a/components/admin/settings/org-settings-sync.tsx +++ b/components/admin/settings/org-settings-sync.tsx @@ -14,13 +14,18 @@ export function OrgSettingsSync() { useEffect(() => { if (!data) return - hydratingRef.current = true - const mapped = apiOrgPolicyToStore(data.policy) - const meta = apiOrgSettingsMeta(data) - useOrgSettingsStore.getState().hydrateFromApi(mapped, meta) - queueMicrotask(() => { - hydratingRef.current = false - }) + try { + hydratingRef.current = true + const mapped = apiOrgPolicyToStore(data.policy) + const meta = apiOrgSettingsMeta(data) + useOrgSettingsStore.getState().hydrateFromApi(mapped, meta) + } catch (err) { + console.error("org settings hydrate failed", err) + } finally { + queueMicrotask(() => { + hydratingRef.current = false + }) + } }, [data]) return null diff --git a/components/admin/settings/sections/agenda-section.tsx b/components/admin/settings/sections/agenda-section.tsx new file mode 100644 index 0000000..0200ee6 --- /dev/null +++ b/components/admin/settings/sections/agenda-section.tsx @@ -0,0 +1,158 @@ +"use client" + +import { useEffect, useState } from "react" +import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" +import { AgendaVideoProviderSelectLabel } from "@/components/agenda/agenda-video-provider-select-label" +import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" +import { + AGENDA_VIDEO_PROVIDER_LABELS, + AGENDA_VIDEO_PROVIDERS, + type AgendaVideoProvider, +} from "@/lib/agenda/agenda-settings-types" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import type { MailThemeMode } from "@/lib/mail-settings/types" + +const THEME_OPTIONS: { id: MailThemeMode; label: string }[] = [ + { id: "light", label: "Clair" }, + { id: "dark", label: "Sombre" }, + { id: "system", label: "Système" }, +] + +export function AgendaSection() { + const agenda = useOrgSettingsStore((s) => s.agenda) + const setAgenda = useOrgSettingsStore((s) => s.setAgenda) + const [draft, setDraft] = useState(agenda) + + useEffect(() => { + setDraft(agenda) + }, [agenda]) + + const updateApiKey = (provider: AgendaVideoProvider, value: string) => { + setDraft((prev) => ({ + ...prev, + video_provider_api_keys: { + ...prev.video_provider_api_keys, + [provider]: value, + }, + })) + } + + return ( + setAgenda(draft)} + > +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+

Clés API visioconférence (organisation)

+

+ Stockées côté serveur. UltiMeet n'exige pas de clé API. +

+ {(["zoom", "google_meet", "teams", "jitsi"] as AgendaVideoProvider[]).map( + (provider) => ( +
+ + updateApiKey(provider, e.target.value)} + /> +
+ ), + )} +
+
+
+ ) +} diff --git a/components/admin/settings/sections/drive-mount-oauth-section.tsx b/components/admin/settings/sections/drive-mount-oauth-section.tsx new file mode 100644 index 0000000..8f2a69b --- /dev/null +++ b/components/admin/settings/sections/drive-mount-oauth-section.tsx @@ -0,0 +1,154 @@ +"use client" + +import { useEffect, useState } from "react" +import { Check, Copy } from "lucide-react" +import { toast } from "sonner" +import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" +import type { DriveMountOAuthProvider, DriveMountOAuthSettings } from "@/lib/admin-settings/org-settings-types" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { buildDriveMountOAuthRedirectURI } from "@/lib/drive/drive-mount-oauth" + +const PROVIDERS: { id: DriveMountOAuthProvider; label: string; hint: string }[] = [ + { + id: "google", + label: "Google Drive", + hint: "Console Google Cloud — API Drive, redirect URI ci-dessous", + }, + { + id: "dropbox", + label: "Dropbox", + hint: "App Dropbox — permissions files.metadata.read, files.content.read/write", + }, + { + id: "microsoft", + label: "Microsoft OneDrive", + hint: "Azure AD — Microsoft Graph Files.ReadWrite", + }, +] + +const SECRET_KEYS: Record = { + google: "mount_oauth_google", + dropbox: "mount_oauth_dropbox", + microsoft: "mount_oauth_microsoft", +} + +export function DriveMountOAuthSection({ + draft, + onChange, +}: { + draft: DriveMountOAuthSettings + onChange: (next: DriveMountOAuthSettings) => void +}) { + const secrets = useOrgSettingsStore((s) => s.meta?.secrets) + const [redirectUri, setRedirectUri] = useState("") + const [copied, setCopied] = useState(false) + + useEffect(() => { + setRedirectUri(buildDriveMountOAuthRedirectURI()) + }, []) + + const updateProvider = (provider: DriveMountOAuthProvider, patch: Partial) => { + onChange({ + ...draft, + [provider]: { ...draft[provider], ...patch }, + }) + } + + const copyRedirectUri = async () => { + const uri = redirectUri || buildDriveMountOAuthRedirectURI() + try { + await navigator.clipboard.writeText(uri) + setCopied(true) + toast.success("URI de redirection copiée") + window.setTimeout(() => setCopied(false), 2000) + } catch { + toast.error("Impossible de copier l'URI") + } + } + + return ( +
+
+

Connexion cloud (OAuth)

+

+ Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive. +

+
+
+ +
+ + +
+

+ Basée sur l'URL actuelle du navigateur. Enregistrez-la chez chaque fournisseur OAuth (Google, Dropbox, Microsoft). +

+
+
+ {PROVIDERS.map(({ id, label, hint }) => { + const provider = draft[id] + const configured = Boolean(secrets?.[SECRET_KEYS[id]]?.configured) + return ( +
+ + {provider.enabled ? ( +
+
+ + updateProvider(id, { client_id: e.target.value })} + autoComplete="off" + /> +
+
+ + updateProvider(id, { client_secret: e.target.value })} + placeholder={configured ? "•••••••• (laisser vide pour conserver)" : "Coller le secret"} + autoComplete="off" + /> + {configured && !provider.client_secret.trim() ? ( +

Secret configuré

+ ) : null} +
+
+ ) : null} +
+ ) + })} +
+
+ ) +} diff --git a/components/admin/settings/sections/drive-org-section.tsx b/components/admin/settings/sections/drive-org-section.tsx new file mode 100644 index 0000000..3125868 --- /dev/null +++ b/components/admin/settings/sections/drive-org-section.tsx @@ -0,0 +1,94 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + useAdminDriveOrgFolderMutations, + useAdminDriveOrgFolders, +} from "@/lib/api/hooks/use-admin-drive-queries" + +export function DriveOrgFoldersSection() { + const folders = useAdminDriveOrgFolders() + const { create, remove, sync } = useAdminDriveOrgFolderMutations() + const [orgSlug, setOrgSlug] = useState("") + const [mountPoint, setMountPoint] = useState("") + const [syncSlugs, setSyncSlugs] = useState("") + + return ( +
+
+

Dossiers d'organisation

+

+ Group folders Nextcloud liés aux organisations Authentik. +

+
+ +
+
+ + setOrgSlug(e.target.value)} placeholder="acme" /> +
+
+ + setMountPoint(e.target.value)} placeholder="Acme Corp" /> +
+
+ + +
+ + setSyncSlugs(e.target.value)} + placeholder="acme, beta" + /> + +
+ +
    + {(folders.data ?? []).map((folder) => ( +
  • +
    +

    {folder.mount_point}

    +

    {folder.org_slug}

    +
    + +
  • + ))} + {folders.data?.length === 0 ? ( +
  • Aucun dossier d'organisation
  • + ) : null} +
+
+ ) +} diff --git a/components/admin/settings/sections/file-policies-section.tsx b/components/admin/settings/sections/file-policies-section.tsx index 3660159..6f79773 100644 --- a/components/admin/settings/sections/file-policies-section.tsx +++ b/components/admin/settings/sections/file-policies-section.tsx @@ -1,5 +1,6 @@ "use client" +import { useEffect, useState } from "react" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { Input } from "@/components/ui/input" @@ -13,10 +14,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { DriveOrgFoldersSection } from "@/components/admin/settings/sections/drive-org-section" +import { DriveMountOAuthSection } from "@/components/admin/settings/sections/drive-mount-oauth-section" export function FilePoliciesSection() { const filePolicies = useOrgSettingsStore((s) => s.filePolicies) const setFilePolicies = useOrgSettingsStore((s) => s.setFilePolicies) + const [mountOAuthDraft, setMountOAuthDraft] = useState(filePolicies.mount_oauth) const vtKeyConfigured = useOrgSettingsStore( (s) => s.meta?.secrets?.virustotal_api_key?.configured ?? false ) @@ -25,11 +29,16 @@ export function FilePoliciesSection() { !vtKeyConfigured && !(filePolicies.virustotal_api_key ?? "").trim() + useEffect(() => { + setMountOAuthDraft(filePolicies.mount_oauth) + }, [filePolicies.mount_oauth]) + return ( setFilePolicies({ mount_oauth: mountOAuthDraft })} >
@@ -143,6 +152,8 @@ export function FilePoliciesSection() {
) : null}
+ +
) } diff --git a/components/admin/settings/sections/ultimeet-section.tsx b/components/admin/settings/sections/ultimeet-section.tsx new file mode 100644 index 0000000..4bfa11a --- /dev/null +++ b/components/admin/settings/sections/ultimeet-section.tsx @@ -0,0 +1,347 @@ +"use client" + +import { useEffect, useState } from "react" +import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" +import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" +import { + MEET_EMAIL_RECIPIENTS_LABELS, + MEET_EXTERNAL_API_PROVIDER_LABELS, + MEET_TRANSCRIPTION_ENGINE_LABELS, + MEET_TRANSCRIPTION_MODE_LABELS, + type MeetOrgPolicySettings, +} from "@/lib/meet/meet-settings-types" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" + +export function UltimeetSection() { + const meet = useOrgSettingsStore((s) => s.meet) + const llmProviders = useOrgSettingsStore((s) => s.llm.providers) + const setMeet = useOrgSettingsStore((s) => s.setMeet) + const effective = useOrgSettingsStore((s) => s.meta?.effective.jitsi) + const [draft, setDraft] = useState(meet) + + useEffect(() => { + setDraft(meet) + }, [meet]) + + const patch = (next: Partial) => + setDraft((prev) => ({ ...prev, ...next })) + + const patchPost = (next: Partial) => + setDraft((prev) => ({ + ...prev, + post_actions: { ...prev.post_actions, ...next }, + })) + + return ( + setMeet(draft)} + > + + + Infrastructure + + Jitsi {effective?.enabled ? "actif" : "inactif"} + {effective?.public_url ? ` — ${effective.public_url}` : ""} + + + + +
+ + + {draft.transcription_enabled ? ( + <> +
+
+ + +
+ +
+ + +
+
+ + + + {draft.transcription_engine === "faster_whisper_local" ? ( +
+
+ + patch({ skynet_url: e.target.value })} + placeholder="http://skynet:8000" + /> +
+
+ + patch({ whisper_model: e.target.value })} + placeholder="tiny, base, small…" + /> +
+
+ ) : ( +
+
+ + +
+
+ + patch({ external_api_url: e.target.value })} + placeholder="https://api.openai.com/v1/audio/transcriptions" + /> +
+
+ + patch({ external_api_key: e.target.value })} + placeholder="Laisser vide pour conserver la clé enregistrée" + autoComplete="off" + /> +
+
+ )} + + ) : null} +
+ + {draft.transcription_enabled ? ( +
+
+

Après la réunion

+

+ Actions exécutées quand le transcript est reçu par le backend. +

+
+ + + {draft.post_actions.drive_enabled ? ( +
+ + patchPost({ drive_folder_path: e.target.value })} + placeholder="/UltiMeet/Transcripts" + /> +
+ ) : null} + + + {draft.post_actions.email_enabled ? ( +
+
+ + +
+ {draft.post_actions.email_recipients === "custom" ? ( +
+ + patchPost({ email_custom_addresses: e.target.value })} + /> +
+ ) : null} +
+ ) : null} + + + {draft.post_actions.llm_enabled ? ( +
+
+ + +
+
+ +