Lots of stuff and mobile app
Some checks failed
E2E / Playwright e2e (push) Has been cancelled

This commit is contained in:
R3D347HR4Y 2026-06-17 00:13:28 +02:00
parent 10d5215a8c
commit d6d18f911b
799 changed files with 18830 additions and 849 deletions

View File

@ -23,5 +23,7 @@ NEXT_PUBLIC_ONLYOFFICE_URL=http://localhost/office
NEXT_PUBLIC_HOCUSPOCUS_URL=ws://localhost/collab
# UltiAI (chemin proxy OpenWebUI — même origine)
NEXT_PUBLIC_AI_PUBLIC_PATH=/ai
# Origine UltiSpace par défaut (picker serveur apps Tauri mobiles)
# NEXT_PUBLIC_ULTISPACE_ORIGIN=https://dev.ultispace.fr
# Dev Next.js (:3000) : charger l'iframe depuis nginx (:80) pour cookies session + proxy OpenWebUI
NEXT_PUBLIC_AI_ORIGIN=http://localhost

5
.gitignore vendored
View File

@ -19,4 +19,7 @@ node_modules
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.cache/
# Temporarily renamed during mobile dev/build (see mobile/scripts/dev-mobile.sh)
middleware.web.ts
out/_next/

View File

@ -212,3 +212,33 @@ Stack dev typique : **nginx (:80)** → `ultid` (Docker) + frontend Next.js (`pn
Après restart backend, vérifier : `curl -s http://127.0.0.1:80/api/v1/ai/config` (JSON, pas 502).
Repo backend : `../ulti-backend` — voir aussi `ulti-backend/CLAUDE.md`.
### Mobile (Tauri 2 — Android + iOS)
Workspace : `mobile/` — voir `mobile/README.md` (build, Phase 0, scaffolds natifs).
**Toolchain Rust : utiliser rustup, pas Homebrew.** Sur macOS, Rust peut coexister via `brew install rust` (`/opt/homebrew/bin`) et rustup (`~/.cargo/bin`). Pour Tauri mobile, **toujours** la toolchain rustup :
- Vérifier avant tout `cargo` / `pnpm tauri` :
```bash
which rustc cargo # attendu : ~/.cargo/bin/rustc et ~/.cargo/bin/cargo
rustc --version
```
- Si `which rustc` pointe vers `/opt/homebrew/bin/rustc`, recharger le shell (`source "$HOME/.cargo/env"`) ou placer `~/.cargo/bin` **avant** `/opt/homebrew/bin` dans le `PATH`.
- Cibles mobiles (une fois) :
```bash
rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
```
Build frontend mobile (prérequis à chaque app Tauri) : `pnpm build:mobile``./out`.
Compile Rust : `cd mobile && cargo check`.
Commandes Tauri (layout imbriqué `mobile/apps/<id>/src-tauri`) :
```bash
pnpm tauri:ultimail android init # pas pnpm tauri --config depuis la racine
pnpm tauri:ultimail android dev
```
Les agents ne doivent **pas** supposer que le Rust Homebrew suffit pour les builds Android/iOS.

View File

@ -1,5 +1,9 @@
import { CompteSettingsSectionFromSegments } from "@/components/compte/compte-settings-section-view"
export function generateStaticParams() {
return [{ section: [] }]
}
export default async function AccountSectionPage({
params,
}: {

View File

@ -1,5 +1,9 @@
import { AdminSettingsSectionFromSegments } from "@/components/admin/settings/admin-settings-section-view"
export function generateStaticParams() {
return [{ section: [] }]
}
export default async function AdminSettingsSectionPage({
params,
}: {

View File

@ -1,12 +1,14 @@
"use client"
import { Suspense } from "react"
import { AgendaPage } from "@/components/agenda/agenda-page"
import AgendaView from "./view"
export function generateStaticParams() {
return [{ segments: [] }]
}
export default function AgendaRoutePage() {
return (
<Suspense fallback={null}>
<AgendaPage />
<AgendaView />
</Suspense>
)
}

View File

@ -0,0 +1,12 @@
"use client"
import { Suspense } from "react"
import { AgendaPage } from "@/components/agenda/agenda-page"
export default function AgendaRoutePage() {
return (
<Suspense fallback={null}>
<AgendaPage />
</Suspense>
)
}

View File

@ -0,0 +1,17 @@
import { CompteSettingsSectionFromSegments } from "@/components/compte/compte-settings-section-view"
// On web, next.config redirects /compte -> /account. In the mobile static export
// there are no redirects, so this page keeps the account UI reachable at /compte
// from every per-app build.
export function generateStaticParams() {
return [{ section: [] }]
}
export default async function ComptePage({
params,
}: {
params: Promise<{ section?: string[] }>
}) {
const { section } = await params
return <CompteSettingsSectionFromSegments segments={section} />
}

View File

@ -1,7 +1,14 @@
"use client"
import { Suspense } from "react"
import ContactsView from "./view"
import { ContactsAppShell } from "@/components/gmail/contacts-page/contacts-app-shell"
export function generateStaticParams() {
return [{ slug: [] }]
}
export default function ContactsPage() {
return <ContactsAppShell />
return (
<Suspense fallback={null}>
<ContactsView />
</Suspense>
)
}

View File

@ -0,0 +1,7 @@
"use client"
import { ContactsAppShell } from "@/components/gmail/contacts-page/contacts-app-shell"
export default function ContactsPage() {
return <ContactsAppShell />
}

View File

@ -1,294 +1,14 @@
"use client"
import { Suspense } from "react"
import DriveBrowserView from "./view"
import { useEffect, useMemo, useState } from "react"
import { useParams, useSearchParams } from "next/navigation"
import { DriveHeader } from "@/components/drive/drive-header"
import { DriveMobileBottomBar } from "@/components/drive/drive-mobile-bottom-bar"
import { DriveBrowserChrome } from "@/components/drive/drive-browser-chrome"
import { FileBrowser } from "@/components/drive/file-browser"
import { DriveMarqueeSurface } from "@/components/drive/drive-marquee-surface"
import { DriveScrollEndSpacer } from "@/components/drive/drive-scroll-end-spacer"
import { parseDriveSegments, folderPathFromSegments } from "@/lib/drive/drive-url"
import {
type DriveSearchScope,
defaultDriveSearchScope,
fileBrowserViewForSearchScope,
parseDriveSearchParams,
} from "@/lib/drive/drive-search"
import { useDriveFilteredItems } from "@/lib/hooks/use-drive-filtered-items"
import { useDriveFiltersStore } from "@/lib/stores/drive-filters-store"
import { useDriveSettingsStore } from "@/lib/stores/drive-settings-store"
import {
DRIVE_BROWSER_CARD_CLASS,
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
DRIVE_MAIN_INSET_X,
} from "@/lib/drive/drive-chrome-classes"
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 function generateStaticParams() {
return [{ segments: [] }]
}
export default function DriveBrowserPage() {
const params = useParams()
const urlSearchParams = useSearchParams()
const segments = params.segments as string[] | undefined
const route = useMemo(() => parseDriveSegments(segments), [segments])
const folderPath = folderPathFromSegments(route.pathSegments)
const contextView =
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
)
const committedSearch = useMemo(() => {
if (route.view !== "search") return null
return parseDriveSearchParams(urlSearchParams, {
scope: fallbackScope,
folderPath,
})
}, [route.view, urlSearchParams, fallbackScope, folderPath])
const [searchInput, setSearchInput] = useState("")
const [searchScope, setSearchScope] = useState<DriveSearchScope>(fallbackScope)
useEffect(() => {
if (route.view === "search" && committedSearch) {
setSearchInput(committedSearch.query)
setSearchScope(committedSearch.scope)
}
}, [route.view, committedSearch?.query, committedSearch?.scope])
useEffect(() => {
if (route.view !== "search") {
setSearchScope(fallbackScope)
}
}, [route.view, fallbackScope])
const filters = useDriveFiltersStore()
const sortField = useDriveSettingsStore((s) => s.sortField)
const sortDir = useDriveSettingsStore((s) => s.sortDir)
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,
"",
route.view === "shared" && route.pathSegments.length === 0
)
const sharedFolder = useDriveList(
folderPath,
route.page,
"",
route.view === "shared" && route.pathSegments.length > 0
)
const recent = useDriveRecent()
const starred = useDriveStarred(folderPath)
const trash = useDriveTrash()
const searchResults = useDriveSearch(
committedSearch?.query ?? "",
committedSearch?.scope ?? "all",
committedSearch?.scope === "folder" ? committedSearch.folderPath : "/",
route.page,
route.view === "search" && Boolean(committedSearch?.query)
)
const active =
route.view === "search"
? searchResults
: route.view === "recent"
? recent
: route.view === "starred"
? starred
: route.view === "trash"
? trash
: route.view === "shared"
? route.pathSegments.length === 0
? shared
: sharedFolder
: route.view === "org"
? orgList
: route.view === "mount"
? mountList
: list
const files = active.data?.files ?? []
const filtersSnapshot = useMemo(
() => ({
types: filters.types,
sources: filters.sources,
contactEmail: filters.contactEmail,
contactName: filters.contactName,
datePreset: filters.datePreset,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
}),
[
filters.types,
filters.sources,
filters.contactEmail,
filters.contactName,
filters.datePreset,
filters.dateFrom,
filters.dateTo,
]
)
const browseWithSubtree =
route.view === "files" ||
(route.view === "shared" && route.pathSegments.length > 0)
const { filteredItems: filteredFiles, corpusLoading } = useDriveFilteredItems(
files,
filtersSnapshot,
{ sortField, sortDir, folderPlacement },
{
recursiveCorpus: browseWithSubtree,
scopePath: folderPath,
}
)
const isLoading = active.isLoading || corpusLoading
const isTrash = route.view === "trash"
const isSearchView = route.view === "search"
const searchBrowserView = committedSearch
? fileBrowserViewForSearchScope(committedSearch.scope)
: "files"
const emptyMessage = isSearchView
? committedSearch?.query
? "Aucun résultat pour cette recherche."
: "Saisissez un terme de recherche."
: "Ce dossier est vide."
return (
<>
<DriveHeader
search={searchInput}
onSearchChange={setSearchInput}
searchScope={searchScope}
onSearchScopeChange={setSearchScope}
folderPath={folderPath}
contextView={contextView}
/>
<div
className={cn(
"flex min-h-0 flex-1 flex-col pb-1 max-sm:pb-0",
DRIVE_MAIN_INSET_X
)}
>
<div className={DRIVE_BROWSER_CARD_CLASS} data-drive-browser-card>
<DriveBrowserChrome
view={route.view}
segments={route.pathSegments}
rootId={route.rootId}
isTrash={isTrash}
items={filteredFiles}
searchState={committedSearch}
/>
<main
data-drive-browser-main
className="flex min-h-0 flex-1 flex-col overflow-auto"
>
<DriveMarqueeSurface
enabled={!isLoading && !active.isError && filteredFiles.length > 0}
className="min-h-full"
>
{isLoading && (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-muted-foreground"
)}
>
Chargement
</p>
)}
{active.isError && (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-destructive"
)}
>
{isSearchView
? "Impossible de charger les résultats de recherche."
: "Impossible de charger les fichiers."}
</p>
)}
{!isLoading && !active.isError && files.length === 0 && (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-muted-foreground"
)}
>
{emptyMessage}
</p>
)}
{!isLoading && !active.isError && files.length > 0 && filteredFiles.length === 0 && (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-muted-foreground"
)}
>
Aucun élément ne correspond aux filtres.
</p>
)}
{filteredFiles.length > 0 ? (
<FileBrowser
items={filteredFiles}
view={
isSearchView
? searchBrowserView
: route.view === "shared"
? "shared"
: route.view
}
rootId={route.rootId}
isTrash={isTrash}
/>
) : null}
<DriveScrollEndSpacer />
</DriveMarqueeSurface>
</main>
</div>
</div>
<DriveMobileBottomBar
search={searchInput}
onSearchChange={setSearchInput}
searchScope={searchScope}
onSearchScopeChange={setSearchScope}
folderPath={folderPath}
contextView={contextView}
resultsMode={isSearchView}
parentPath={folderPath}
/>
</>
<Suspense fallback={null}>
<DriveBrowserView />
</Suspense>
)
}

View File

@ -0,0 +1,294 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useParams, useSearchParams } from "next/navigation"
import { DriveHeader } from "@/components/drive/drive-header"
import { DriveMobileBottomBar } from "@/components/drive/drive-mobile-bottom-bar"
import { DriveBrowserChrome } from "@/components/drive/drive-browser-chrome"
import { FileBrowser } from "@/components/drive/file-browser"
import { DriveMarqueeSurface } from "@/components/drive/drive-marquee-surface"
import { DriveScrollEndSpacer } from "@/components/drive/drive-scroll-end-spacer"
import { parseDriveSegments, folderPathFromSegments } from "@/lib/drive/drive-url"
import {
type DriveSearchScope,
defaultDriveSearchScope,
fileBrowserViewForSearchScope,
parseDriveSearchParams,
} from "@/lib/drive/drive-search"
import { useDriveFilteredItems } from "@/lib/hooks/use-drive-filtered-items"
import { useDriveFiltersStore } from "@/lib/stores/drive-filters-store"
import { useDriveSettingsStore } from "@/lib/stores/drive-settings-store"
import {
DRIVE_BROWSER_CARD_CLASS,
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
DRIVE_MAIN_INSET_X,
} from "@/lib/drive/drive-chrome-classes"
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()
const urlSearchParams = useSearchParams()
const segments = params.segments as string[] | undefined
const route = useMemo(() => parseDriveSegments(segments), [segments])
const folderPath = folderPathFromSegments(route.pathSegments)
const contextView =
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
)
const committedSearch = useMemo(() => {
if (route.view !== "search") return null
return parseDriveSearchParams(urlSearchParams, {
scope: fallbackScope,
folderPath,
})
}, [route.view, urlSearchParams, fallbackScope, folderPath])
const [searchInput, setSearchInput] = useState("")
const [searchScope, setSearchScope] = useState<DriveSearchScope>(fallbackScope)
useEffect(() => {
if (route.view === "search" && committedSearch) {
setSearchInput(committedSearch.query)
setSearchScope(committedSearch.scope)
}
}, [route.view, committedSearch?.query, committedSearch?.scope])
useEffect(() => {
if (route.view !== "search") {
setSearchScope(fallbackScope)
}
}, [route.view, fallbackScope])
const filters = useDriveFiltersStore()
const sortField = useDriveSettingsStore((s) => s.sortField)
const sortDir = useDriveSettingsStore((s) => s.sortDir)
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,
"",
route.view === "shared" && route.pathSegments.length === 0
)
const sharedFolder = useDriveList(
folderPath,
route.page,
"",
route.view === "shared" && route.pathSegments.length > 0
)
const recent = useDriveRecent()
const starred = useDriveStarred(folderPath)
const trash = useDriveTrash()
const searchResults = useDriveSearch(
committedSearch?.query ?? "",
committedSearch?.scope ?? "all",
committedSearch?.scope === "folder" ? committedSearch.folderPath : "/",
route.page,
route.view === "search" && Boolean(committedSearch?.query)
)
const active =
route.view === "search"
? searchResults
: route.view === "recent"
? recent
: route.view === "starred"
? starred
: route.view === "trash"
? trash
: route.view === "shared"
? route.pathSegments.length === 0
? shared
: sharedFolder
: route.view === "org"
? orgList
: route.view === "mount"
? mountList
: list
const files = active.data?.files ?? []
const filtersSnapshot = useMemo(
() => ({
types: filters.types,
sources: filters.sources,
contactEmail: filters.contactEmail,
contactName: filters.contactName,
datePreset: filters.datePreset,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
}),
[
filters.types,
filters.sources,
filters.contactEmail,
filters.contactName,
filters.datePreset,
filters.dateFrom,
filters.dateTo,
]
)
const browseWithSubtree =
route.view === "files" ||
(route.view === "shared" && route.pathSegments.length > 0)
const { filteredItems: filteredFiles, corpusLoading } = useDriveFilteredItems(
files,
filtersSnapshot,
{ sortField, sortDir, folderPlacement },
{
recursiveCorpus: browseWithSubtree,
scopePath: folderPath,
}
)
const isLoading = active.isLoading || corpusLoading
const isTrash = route.view === "trash"
const isSearchView = route.view === "search"
const searchBrowserView = committedSearch
? fileBrowserViewForSearchScope(committedSearch.scope)
: "files"
const emptyMessage = isSearchView
? committedSearch?.query
? "Aucun résultat pour cette recherche."
: "Saisissez un terme de recherche."
: "Ce dossier est vide."
return (
<>
<DriveHeader
search={searchInput}
onSearchChange={setSearchInput}
searchScope={searchScope}
onSearchScopeChange={setSearchScope}
folderPath={folderPath}
contextView={contextView}
/>
<div
className={cn(
"flex min-h-0 flex-1 flex-col pb-1 max-sm:pb-0",
DRIVE_MAIN_INSET_X
)}
>
<div className={DRIVE_BROWSER_CARD_CLASS} data-drive-browser-card>
<DriveBrowserChrome
view={route.view}
segments={route.pathSegments}
rootId={route.rootId}
isTrash={isTrash}
items={filteredFiles}
searchState={committedSearch}
/>
<main
data-drive-browser-main
className="flex min-h-0 flex-1 flex-col overflow-auto"
>
<DriveMarqueeSurface
enabled={!isLoading && !active.isError && filteredFiles.length > 0}
className="min-h-full"
>
{isLoading && (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-muted-foreground"
)}
>
Chargement
</p>
)}
{active.isError && (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-destructive"
)}
>
{isSearchView
? "Impossible de charger les résultats de recherche."
: "Impossible de charger les fichiers."}
</p>
)}
{!isLoading && !active.isError && files.length === 0 && (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-muted-foreground"
)}
>
{emptyMessage}
</p>
)}
{!isLoading && !active.isError && files.length > 0 && filteredFiles.length === 0 && (
<p
className={cn(
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
"py-8 text-center text-muted-foreground"
)}
>
Aucun élément ne correspond aux filtres.
</p>
)}
{filteredFiles.length > 0 ? (
<FileBrowser
items={filteredFiles}
view={
isSearchView
? searchBrowserView
: route.view === "shared"
? "shared"
: route.view
}
rootId={route.rootId}
isTrash={isTrash}
/>
) : null}
<DriveScrollEndSpacer />
</DriveMarqueeSurface>
</main>
</div>
</div>
<DriveMobileBottomBar
search={searchInput}
onSearchChange={setSearchInput}
searchScope={searchScope}
onSearchScopeChange={setSearchScope}
folderPath={folderPath}
contextView={contextView}
resultsMode={isSearchView}
parentPath={folderPath}
/>
</>
)
}

View File

@ -349,6 +349,19 @@ body {
text-size-adjust: 100%;
}
/* Tauri Android/iOS: respect status bar when edge-to-edge (viewport-fit: cover). */
html.native-shell body {
padding-top: max(env(safe-area-inset-top, 0px), var(--native-safe-top, 0px));
padding-bottom: env(safe-area-inset-bottom, 0px);
box-sizing: border-box;
}
/* Native shells never use the web first-launch splash (blocks touch if left visible). */
html.native-shell .app-first-launch-splash {
display: none !important;
pointer-events: none !important;
}
/* Mail UI: text selection only in fields and message previews */
.ultimail-app {
height: 100dvh;
@ -467,6 +480,7 @@ body {
justify-content: center;
overflow: hidden;
padding: clamp(1rem, 3vw, 2rem);
pointer-events: auto;
background:
radial-gradient(circle at 18% 20%, color-mix(in srgb, #1a73e8 32%, transparent) 0%, transparent 46%),
radial-gradient(circle at 80% 15%, color-mix(in srgb, #34a853 26%, transparent) 0%, transparent 40%),
@ -486,6 +500,7 @@ html[data-splash-seen='1'] .app-first-launch-splash {
.app-first-launch-splash--hide {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.app-first-launch-splash__aurora {
@ -618,6 +633,209 @@ html:has(.ultimail-login) body {
background-color: transparent !important;
}
/* ── Login : fond animé (orbes + grille + aurore), aligné sur la landing ── */
.ultimail-login {
--login-bg: #f7f8fc;
--login-line: rgba(21, 24, 30, 0.07);
--login-glow-a: #4f6df5;
--login-glow-b: #9a5cf0;
--login-glow-c: #1fb6c9;
--login-card-glass: rgba(255, 255, 255, 0.58);
--login-card-glass-border: rgba(21, 24, 30, 0.1);
--login-card-glass-highlight: rgba(255, 255, 255, 0.72);
--login-card-glass-blur: 2px;
--landing-glow-a: var(--login-glow-a);
--landing-glow-b: var(--login-glow-b);
--landing-glow-c: var(--login-glow-c);
background-color: var(--login-bg);
overflow: hidden;
}
.dark .ultimail-login {
--login-bg: #0b0d12;
--login-card-glass: rgba(12, 14, 20, 0.52);
--login-card-glass-border: rgba(255, 255, 255, 0.09);
--login-card-glass-highlight: rgba(255, 255, 255, 0.07);
--login-text: #e8eaed;
--login-text-strong: #ffffff;
--login-text-muted: #bdc1c6;
--login-text-link: #8ab4f8;
--login-line: rgba(238, 240, 246, 0.08);
--login-glow-a: #5d7bff;
--login-glow-b: #a86bff;
--login-glow-c: #2cc8dc;
}
.dark .ultimail-login [data-slot='card'] {
color: var(--login-text);
}
.dark .ultimail-login [data-slot='card-description'],
.dark .ultimail-login [data-slot='card-footer'] p {
color: var(--login-text-muted) !important;
}
.dark .ultimail-login [data-slot='card'] .font-bold {
color: var(--login-text-strong);
}
.ultimail-login .landing-gradient-text {
display: inline;
background-image: linear-gradient(
100deg,
var(--login-glow-a) 0%,
var(--login-glow-b) 28%,
var(--login-glow-c) 55%,
var(--login-glow-a) 80%
);
background-size: 220% 100%;
background-repeat: repeat;
background-clip: text;
-webkit-background-clip: text;
color: transparent !important;
-webkit-text-fill-color: transparent !important;
animation: landing-gradient-pan 9s linear infinite;
}
.dark .ultimail-login [data-slot='card'] a.text-primary {
color: var(--login-text-link) !important;
}
.ultimail-login [data-slot='card'] {
position: relative;
isolation: isolate;
background: transparent !important;
border: 1px solid var(--login-card-glass-border) !important;
box-shadow:
inset 0 1px 0 var(--login-card-glass-highlight),
0 10px 36px -14px rgb(0 0 0 / 18%);
}
.ultimail-login [data-slot='card']::before {
content: '';
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background: var(--login-card-glass);
-webkit-backdrop-filter: blur(var(--login-card-glass-blur)) saturate(1.45);
backdrop-filter: blur(var(--login-card-glass-blur)) saturate(1.45);
pointer-events: none;
}
.ultimail-login [data-slot='card'] > * {
position: relative;
z-index: 1;
}
.dark .ultimail-login [data-slot='card'] {
box-shadow:
inset 0 1px 0 var(--login-card-glass-highlight),
0 14px 40px -16px rgb(0 0 0 / 55%);
}
.ultimail-login-backdrop {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.ultimail-login-backdrop::after {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, var(--login-line) 1px, transparent 1px),
linear-gradient(to bottom, var(--login-line) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 80% 70% at 50% 45%, black 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 70% at 50% 45%, black 30%, transparent 80%);
}
.ultimail-login-aurora {
position: absolute;
inset: 0;
background: radial-gradient(
60% 50% at 50% 38%,
color-mix(in oklab, var(--login-glow-a) 12%, transparent),
transparent 70%
);
}
.ultimail-login-orb {
position: absolute;
border-radius: 9999px;
filter: blur(90px);
opacity: 0.5;
will-change: transform;
}
.dark .ultimail-login-orb {
opacity: 0.4;
}
.ultimail-login-orb--a {
width: 40rem;
height: 40rem;
top: -16rem;
left: -12rem;
background: radial-gradient(circle at 30% 30%, var(--login-glow-a), transparent 65%);
animation: login-drift-a 26s ease-in-out infinite alternate;
}
.ultimail-login-orb--b {
width: 34rem;
height: 34rem;
top: -10rem;
right: -12rem;
background: radial-gradient(circle at 60% 40%, var(--login-glow-b), transparent 65%);
animation: login-drift-b 32s ease-in-out infinite alternate;
}
.ultimail-login-orb--c {
width: 32rem;
height: 32rem;
bottom: -14rem;
left: 50%;
background: radial-gradient(circle at 50% 50%, var(--login-glow-c), transparent 65%);
animation: login-drift-c 38s ease-in-out infinite alternate;
}
@keyframes login-drift-a {
from {
transform: translate3d(0, 0, 0) scale(1);
}
to {
transform: translate3d(6rem, 4rem, 0) scale(1.12);
}
}
@keyframes login-drift-b {
from {
transform: translate3d(0, 0, 0) scale(1.05);
}
to {
transform: translate3d(-5rem, 6rem, 0) scale(0.94);
}
}
@keyframes login-drift-c {
from {
transform: translate3d(-50%, 0, 0) scale(1);
}
to {
transform: translate3d(-38%, -4rem, 0) scale(1.18);
}
}
@media (prefers-reduced-motion: reduce) {
.ultimail-login-orb {
animation: none !important;
}
}
/* ── Drive : pas de fond décoratif mail ── */
html[data-route-scope='drive']::before,
html:has([data-drive-app])::before {
@ -634,8 +852,22 @@ html[data-route-scope='drive'] body {
@media (min-width: 640px) {
.ultimail-login-card-frame {
padding: 3px;
border-radius: var(--radius-xl);
--login-card-outer-radius: 3rem;
--login-card-border-width: 3px;
position: relative;
border-radius: var(--login-card-outer-radius);
background: transparent;
box-shadow: 0 16px 40px rgb(0 0 0 / 14%);
}
/* Arc-en-ciel confiné à l'anneau — le verre fumé floute le fond login, pas le gradient */
.ultimail-login-card-frame::before {
content: '';
position: absolute;
inset: 0;
z-index: 2;
border-radius: inherit;
padding: var(--login-card-border-width);
background: conic-gradient(
from 145deg,
#1a73e8,
@ -644,12 +876,300 @@ html[data-route-scope='drive'] body {
#ea4335,
#1a73e8
);
box-shadow: 0 16px 40px rgb(0 0 0 / 14%);
pointer-events: none;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
}
.ultimail-login-card-frame > [data-slot='card'] {
border-width: 0;
border-radius: calc(var(--radius-xl) - 3px);
position: relative;
z-index: 1;
border-width: 0 !important;
border-radius: var(--login-card-outer-radius) !important;
}
.dark .ultimail-login-card-frame {
box-shadow: 0 16px 40px rgb(0 0 0 / 45%);
}
}
/* Bouton connexion UltiSpace — fond noir cosmos */
@keyframes login-connect-border-spin {
to {
transform: rotate(360deg);
}
}
@keyframes login-connect-shimmer {
from {
transform: translateX(-130%) skewX(-16deg);
opacity: 0;
}
35% {
opacity: 0.45;
}
to {
transform: translateX(230%) skewX(-16deg);
opacity: 0;
}
}
.ultimail-login .ultimail-login-connect-border {
position: relative;
display: inline-flex;
width: 100%;
padding: 2px;
border-radius: 9999px;
overflow: hidden;
isolation: isolate;
background: transparent;
box-shadow:
0 8px 28px -12px rgb(0 0 0 / 70%),
0 0 0 1px rgb(255 255 255 / 0.05);
transition:
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.35s ease;
}
.ultimail-login .ultimail-login-connect-border::before {
content: '';
position: absolute;
inset: -120%;
z-index: 0;
transform-origin: center center;
will-change: transform;
background: conic-gradient(
from 0deg,
rgb(99 102 241 / 95%),
rgb(168 85 247 / 90%) 25%,
rgb(34 211 238 / 85%) 50%,
rgb(129 140 248 / 90%) 75%,
rgb(99 102 241 / 95%)
);
opacity: 0.85;
transition: opacity 0.35s ease;
}
.ultimail-login .ultimail-login-connect-border:not(:hover)::before {
animation: login-connect-border-spin 8s linear infinite;
}
.ultimail-login .ultimail-login-connect-border::after {
content: '';
position: absolute;
inset: -1px;
z-index: 0;
border-radius: inherit;
box-shadow:
inset 0 0 0 1px rgb(255 255 255 / 0.08),
0 0 18px -4px rgb(99 102 241 / 25%);
pointer-events: none;
opacity: 0.7;
transform: scale(1);
transition:
box-shadow 0.45s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.45s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
.dark .ultimail-login .ultimail-login-connect-border {
box-shadow:
0 8px 28px -12px rgb(0 0 0 / 80%),
0 0 0 1px rgb(255 255 255 / 0.07);
}
.dark .ultimail-login .ultimail-login-connect-border::before {
opacity: 0.95;
}
.ultimail-login .ultimail-login-connect-border:hover {
transform: translateY(-3px) scale(1.015);
box-shadow:
0 20px 40px -14px rgb(0 0 0 / 75%),
0 0 28px -6px rgb(99 102 241 / 45%),
0 0 48px -12px rgb(34 211 238 / 25%);
}
.ultimail-login .ultimail-login-connect-border:hover::before {
animation: none;
transform: rotate(180deg);
opacity: 1;
transition:
transform 0.65s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.35s ease;
}
.ultimail-login .ultimail-login-connect-border:hover::after {
opacity: 1;
transform: scale(1.02);
box-shadow:
inset 0 0 0 1px rgb(255 255 255 / 0.14),
0 0 24px -2px rgb(129 140 248 / 55%),
0 0 40px -6px rgb(34 211 238 / 35%);
}
.ultimail-login .ultimail-login-connect-border:active {
transform: translateY(-1px) scale(1.005);
transition-duration: 0.12s;
}
.ultimail-login a.ultimail-login-connect-btn {
position: relative;
z-index: 1;
isolation: isolate;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
height: 3rem;
padding: 0 1.5rem;
border: 0;
border-radius: 9999px;
overflow: hidden;
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
color: #fff !important;
text-decoration: none !important;
text-shadow: 0 1px 3px rgb(0 0 0 / 60%);
background:
radial-gradient(circle at 15% 25%, rgb(255 255 255 / 70%) 0, rgb(255 255 255 / 70%) 0.45px, transparent 1px),
radial-gradient(circle at 62% 18%, rgb(255 255 255 / 55%) 0, rgb(255 255 255 / 55%) 0.4px, transparent 1px),
radial-gradient(circle at 82% 68%, rgb(255 255 255 / 60%) 0, rgb(255 255 255 / 60%) 0.45px, transparent 1px),
radial-gradient(circle at 35% 78%, rgb(255 255 255 / 45%) 0, rgb(255 255 255 / 45%) 0.35px, transparent 1px),
radial-gradient(ellipse 70% 50% at 20% 0%, rgb(99 102 241 / 12%), transparent 55%),
radial-gradient(ellipse 60% 45% at 90% 100%, rgb(34 211 238 / 8%), transparent 50%),
linear-gradient(180deg, #050505 0%, #000 55%, #030303 100%) !important;
background-size: 100% 100% !important;
background-repeat: no-repeat !important;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.07),
inset 0 -1px 0 rgb(0 0 0 / 0.5);
transition:
box-shadow 0.35s cubic-bezier(0.22, 1, 0.36, 1),
filter 0.35s ease,
letter-spacing 0.35s ease;
}
.ultimail-login a.ultimail-login-connect-btn > * {
position: relative;
z-index: 1;
}
.ultimail-login a.ultimail-login-connect-btn::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background: radial-gradient(
ellipse 90% 60% at 50% 0%,
rgb(129 140 248 / 8%),
transparent 70%
);
opacity: 0.85;
pointer-events: none;
transition:
opacity 0.35s ease,
transform 0.35s ease;
}
.ultimail-login a.ultimail-login-connect-btn::after {
content: '';
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background: linear-gradient(
105deg,
transparent 35%,
rgb(255 255 255 / 0.14) 50%,
transparent 65%
);
transform: translateX(-130%) skewX(-16deg);
opacity: 0;
pointer-events: none;
}
.dark .ultimail-login a.ultimail-login-connect-btn {
background:
radial-gradient(circle at 15% 25%, rgb(255 255 255 / 75%) 0, rgb(255 255 255 / 75%) 0.45px, transparent 1px),
radial-gradient(circle at 62% 18%, rgb(255 255 255 / 60%) 0, rgb(255 255 255 / 60%) 0.4px, transparent 1px),
radial-gradient(circle at 82% 68%, rgb(255 255 255 / 65%) 0, rgb(255 255 255 / 65%) 0.45px, transparent 1px),
radial-gradient(circle at 35% 78%, rgb(255 255 255 / 50%) 0, rgb(255 255 255 / 50%) 0.35px, transparent 1px),
radial-gradient(ellipse 70% 50% at 20% 0%, rgb(129 140 248 / 14%), transparent 55%),
radial-gradient(ellipse 60% 45% at 90% 100%, rgb(34 211 238 / 10%), transparent 50%),
linear-gradient(180deg, #000 0%, #000 55%, #020202 100%) !important;
background-size: 100% 100% !important;
background-repeat: no-repeat !important;
}
.ultimail-login .ultimail-login-connect-border:hover a.ultimail-login-connect-btn {
filter: brightness(1.14);
letter-spacing: 0.01em;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.12),
inset 0 -1px 0 rgb(0 0 0 / 0.4),
inset 0 0 24px -8px rgb(99 102 241 / 35%);
}
.ultimail-login .ultimail-login-connect-border:hover a.ultimail-login-connect-btn::before {
opacity: 1;
transform: scale(1.08);
background: radial-gradient(
ellipse 100% 70% at 50% -10%,
rgb(129 140 248 / 18%),
transparent 72%
);
}
.ultimail-login .ultimail-login-connect-border:hover a.ultimail-login-connect-btn::after {
animation: login-connect-shimmer 0.85s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@media (min-width: 640px) {
.ultimail-login .ultimail-login-connect-border {
width: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.ultimail-login .ultimail-login-connect-border,
.ultimail-login a.ultimail-login-connect-btn {
transition: none;
}
.ultimail-login .ultimail-login-connect-border:not(:hover)::before,
.ultimail-login .ultimail-login-connect-border::after,
.ultimail-login .ultimail-login-connect-border:hover a.ultimail-login-connect-btn::after,
.ultimail-login .landing-gradient-text {
animation: none !important;
}
.ultimail-login .ultimail-login-connect-border:hover::before {
transform: none;
}
.ultimail-login .ultimail-login-connect-border:hover::after {
transform: none;
}
.ultimail-login .ultimail-login-connect-border:hover {
transform: none;
}
.ultimail-login .ultimail-login-connect-border:active {
transform: none;
}
}

View File

@ -2,17 +2,24 @@ import type { Metadata, Viewport } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import { Analytics } from '@vercel/analytics/next'
import './globals.css'
import { ClientThemeShell } from '@/components/suite/client-theme-shell'
import { ThemeInitScript } from '@/components/theme-init-script'
import { FirstLaunchSplash } from '@/components/first-launch-splash'
import { QueryProvider } from '@/lib/api/query-provider'
import { AuthProvider } from '@/components/auth/auth-provider'
import { SessionGuard } from '@/components/auth/session-guard'
import { NativeBridgeProvider } from '@/components/mobile/native-bridge-provider'
import { NativeAuthGate } from '@/components/mobile/native-auth-gate'
import { NativeShellChrome } from '@/components/mobile/native-shell-chrome'
import { MobileLayoutRoot } from '@/components/mobile/mobile-layout-root'
import { MailToaster } from '@/components/gmail/mail-toaster'
import { suiteRootMetadata } from '@/lib/suite/page-metadata'
const geistSans = Geist({ subsets: ['latin'], variable: '--font-geist-sans' })
const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-geist-mono' })
const isMobileBuild = process.env.NEXT_PUBLIC_MOBILE === '1'
export const metadata: Metadata = suiteRootMetadata()
/** Fit visible viewport on tablet/mobile; disable pinch/double-tap zoom on the shell. */
@ -36,15 +43,26 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} h-dvh max-h-dvh overflow-hidden`}
>
<body className="h-dvh max-h-dvh overflow-hidden bg-background font-sans antialiased touch-manipulation">
<ThemeInitScript />
<QueryProvider>
<AuthProvider>
<SessionGuard />
<FirstLaunchSplash>{children}</FirstLaunchSplash>
</AuthProvider>
</QueryProvider>
<MailToaster />
{process.env.NODE_ENV === 'production' && <Analytics />}
{isMobileBuild ? (
<MobileLayoutRoot>{children}</MobileLayoutRoot>
) : (
<ClientThemeShell>
<ThemeInitScript />
<QueryProvider>
<AuthProvider>
<SessionGuard />
<NativeBridgeProvider>
<NativeShellChrome />
<NativeAuthGate>
<FirstLaunchSplash>{children}</FirstLaunchSplash>
</NativeAuthGate>
</NativeBridgeProvider>
</AuthProvider>
</QueryProvider>
<MailToaster />
{process.env.NODE_ENV === 'production' && <Analytics />}
</ClientThemeShell>
)}
</body>
</html>
)

View File

@ -2,22 +2,10 @@
import { useSearchParams } from "next/navigation"
import { Suspense } from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from "@/components/ui/card"
import { LoginForm } from "@/components/auth/login-form"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
import { cn } from "@/lib/utils"
const LOGIN_CARD_CLASS = cn(
"w-full gap-4 border-0 bg-transparent px-4 py-6 shadow-none",
"sm:gap-5 sm:bg-card sm:dark:bg-mail-surface-elevated sm:px-8 sm:py-8",
"sm:text-card-foreground sm:dark:text-mail-text sm:shadow-none"
)
import { useNativeRuntime } from "@/lib/platform"
import { NativeLogin } from "@/components/mobile/native-login"
function LoginContent() {
const searchParams = useSearchParams()
@ -26,53 +14,16 @@ function LoginContent() {
const loginHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
const signupHref = getAuthentikEnrollmentUrl()
if (useNativeRuntime()) {
return <NativeLogin returnTo={returnTo} />
}
return (
<div className="flex flex-1 flex-col items-center justify-center px-4">
<div className="ultimail-login-card-frame mx-auto w-full max-w-sm">
<Card className={LOGIN_CARD_CLASS}>
<CardHeader className="gap-4 px-0 text-center sm:px-0">
<div className="flex flex-col items-center gap-3 py-4">
<img
src="/ultisuite-mark.svg"
alt=""
width={72}
height={72}
draggable={false}
className="h-16 w-16 select-none"
aria-hidden
/>
<span className="text-2xl font-bold tracking-tight">
Ulti<span className="text-[#4285F4]">Suite</span>
</span>
</div>
<CardDescription>
Connecte-toi avec ton compte Ulti (Authentik) pour accéder à ta
suite : mail, drive, contacts et IA.
</CardDescription>
{error ? (
<p className="text-sm text-destructive" role="alert">
{decodeURIComponent(error)}
</p>
) : null}
</CardHeader>
<CardContent className="flex justify-center px-0 sm:px-0">
<Button asChild size="lg" className="w-full sm:w-auto">
<a href={loginHref}>Se connecter</a>
</Button>
</CardContent>
<CardFooter className="px-0 sm:px-0">
<p className="w-full text-center text-sm text-muted-foreground">
Pas encore de compte ?{" "}
<a className="font-medium text-primary underline" href={signupHref}>
Créer un compte
</a>
</p>
</CardFooter>
</Card>
</div>
</div>
<LoginForm
loginHref={loginHref}
signupHref={signupHref}
error={error}
/>
)
}

View File

@ -1,4 +1,11 @@
/** Route catch-all : toute l'interface est rendue par `app/mail/layout.tsx` pour conserver le state React entre changements d'URL. */
// Static export (mobile): pre-render the shell at the segment root; deeper
// paths are handled client-side by the Next router + native deep-links.
export function generateStaticParams() {
return [{ segments: [] }]
}
export default function MailSegmentsPage() {
return null
}

View File

@ -34,8 +34,6 @@ import { useMailStore } from "@/lib/stores/mail-store"
import { useMailUiStore } from "@/lib/stores/mail-ui-store"
import { DEFAULT_INBOX_TAB } from "@/lib/mail-url"
import { cn } from "@/lib/utils"
import { ThemeProvider } from "@/components/theme-provider"
import { MailThemeApplier } from "@/components/gmail/mail-theme-applier"
import { QuickSettingsRoot } from "@/components/gmail/quick-settings/quick-settings-root"
import { MailSettingsSync } from "@/components/gmail/mail-settings-sync"
import { MailNavSync } from "@/components/gmail/mail-nav-sync"
@ -235,7 +233,6 @@ export function MailAppShell({
}, [])
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ComposeProvider>
<ScheduledMailProvider>
<EmailDragProvider>
@ -249,7 +246,6 @@ export function MailAppShell({
>
<MailAppInner />
</Suspense>
<MailThemeApplier />
<MailSettingsSync />
<MailNavSync />
<ComposeIdentitiesSync />
@ -263,6 +259,5 @@ export function MailAppShell({
</EmailDragProvider>
</ScheduledMailProvider>
</ComposeProvider>
</ThemeProvider>
)
}

View File

@ -1,6 +1,8 @@
import type { Metadata } from "next"
import { redirect } from "next/navigation"
import { LandingPage } from "@/components/landing/landing-page"
import { MobileRootRedirect } from "@/components/mobile/mobile-root-redirect"
import { IS_MOBILE_BUILD } from "@/lib/platform"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
@ -18,6 +20,10 @@ export default async function Home({
}: {
searchParams: HomeSearchParams
}) {
// Mobile static export: no server runtime, so never read searchParams here.
if (IS_MOBILE_BUILD) {
return <MobileRootRedirect />
}
const sp = await searchParams
const raw = sp.mail
const mail = Array.isArray(raw) ? raw[0] : raw

View File

@ -2,6 +2,10 @@ import { redirect } from "next/navigation"
import { MailSettingsSectionFromSegments } from "@/components/gmail/settings/mail-settings-section-view"
import { MAIL_SETTINGS_BASE_PATH } from "@/lib/mail-settings/settings-nav"
export function generateStaticParams() {
return [{ section: [] }]
}
export default async function SettingsSectionPage({
params,
}: {

View File

@ -25,12 +25,6 @@ import {
} from "@/components/ui/select"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
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)
@ -53,39 +47,11 @@ export function AgendaSection() {
return (
<OrgSettingsSection
title={ULTICAL_APP_NAME}
description="Thème et visioconférence par défaut pour toute l'organisation."
description="Visioconférence par défaut pour toute l'organisation."
policySection="agenda"
beforeSave={() => setAgenda(draft)}
>
<AutomationTabMasonry columns={2}>
<SettingsCard title="Thème" description="Mode clair/sombre par défaut de l'agenda.">
<SettingsToggleRow
title="Imposer le thème organisationnel"
description="Les utilisateurs ne peuvent plus changer le mode clair/sombre."
checked={draft.enforce_org_theme}
onCheckedChange={(v) => setDraft((p) => ({ ...p, enforce_org_theme: v }))}
/>
<SettingsField label="Thème par défaut">
<Select
value={draft.default_theme_mode}
onValueChange={(v) =>
setDraft((p) => ({ ...p, default_theme_mode: v as MailThemeMode }))
}
>
<SelectTrigger className="h-9 w-full max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THEME_OPTIONS.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsField>
</SettingsCard>
<SettingsCard
title="Visioconférence"
description="Fournisseur visio par défaut pour les événements."

View File

@ -2,7 +2,6 @@
import { useEffect, useLayoutEffect, type ReactNode } from "react"
import { AiChatPanel } from "@/components/ai/ai-chat-panel"
import { AgendaOrgPolicySync } from "@/components/agenda/agenda-org-policy-sync"
import { AgendaQuickSettingsRoot } from "@/components/agenda/agenda-quick-settings-panel"
import { ComposeIdentitiesSync } from "@/components/gmail/compose-identities-sync"
import { AgendaRouteRootProvider } from "@/lib/agenda/agenda-route-context"
@ -49,7 +48,6 @@ export function AgendaAppShell({
{children}
<AiChatPanel />
<ComposeIdentitiesSync />
<AgendaOrgPolicySync />
<AgendaQuickSettingsRoot />
</div>
</TooltipProvider>

View File

@ -1,28 +0,0 @@
"use client"
import { useEffect, useRef } from "react"
import { useCurrentUser } from "@/lib/api/hooks/use-current-user"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import type { MailThemeMode } from "@/lib/mail-settings/types"
/** Applique le thème organisationnel imposé sur le store mail partagé. */
export function AgendaOrgPolicySync() {
const { data: user } = useCurrentUser()
const appliedRef = useRef<MailThemeMode | null>(null)
const enforceOrgTheme = user?.org_agenda?.enforce_org_theme ?? false
const orgThemeMode = user?.org_agenda?.default_theme_mode
useEffect(() => {
if (!enforceOrgTheme || !orgThemeMode) return
if (appliedRef.current === orgThemeMode) return
const current = useMailSettingsStore.getState().themeMode
if (current !== orgThemeMode) {
useMailSettingsStore.getState().setThemeMode(orgThemeMode)
}
appliedRef.current = orgThemeMode
}, [enforceOrgTheme, orgThemeMode])
return null
}

View File

@ -123,8 +123,6 @@ export function AgendaSettingsFields({
variant?: "panel" | "page"
onOpenThemeDialog?: () => void
}) {
const effective = useEffectiveAgendaSettings()
const isDemo = useIsDemoApp()
const { themeMode, setThemeMode } = useThemeModeControls()
const defaultVideoProvider = useAgendaSettingsStore((s) => s.defaultVideoProvider)
@ -170,7 +168,8 @@ export function AgendaSettingsFields({
const identityOptions = useAgendaSettingsIdentityOptions()
const destinationOptions = useAgendaSettingsDestinationOptions()
const activeTheme = effective.orgEnforcesTheme ? effective.themeMode : themeMode
const effective = useEffectiveAgendaSettings()
const isDemo = useIsDemoApp()
const activeProvider = effective.orgEnforcesVideoProvider
? effective.defaultVideoProvider
: defaultVideoProvider
@ -202,7 +201,7 @@ export function AgendaSettingsFields({
<button
type="button"
className="shrink-0 text-xs text-[#1a73e8] hover:underline disabled:opacity-50"
disabled={effective.orgEnforcesTheme}
disabled={false}
onClick={onOpenThemeDialog}
>
Arrière-plan
@ -210,21 +209,15 @@ export function AgendaSettingsFields({
) : null
}
>
{effective.orgEnforcesTheme ? (
<p className="text-[11px] text-muted-foreground">
Thème imposé par votre organisation.
</p>
) : null}
<div className="grid grid-cols-3 gap-1.5">
{THEME_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
disabled={effective.orgEnforcesTheme}
onClick={() => setThemeMode(opt.id)}
className={cn(
"rounded-lg border-2 p-1 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
activeTheme === opt.id
"rounded-lg border-2 p-1 text-left transition-colors",
themeMode === opt.id
? "border-primary bg-accent/60"
: "border-border hover:border-muted-foreground/50 hover:bg-accent/40",
)}

View File

@ -14,6 +14,14 @@ import {
useSessionGuardStore,
} from "@/lib/auth/session-guard-store"
import { isAuthPublicPath } from "@/lib/auth/public-paths"
import { useNativeRuntime } from "@/lib/platform"
import {
ensureNativeAccessToken,
loadNativeSession,
} from "@/lib/auth/native-session"
import { nativeLogout } from "@/lib/auth/native-auth"
import { getRuntimeConfig } from "@/lib/runtime-config"
import { hydrateNativeRuntimeConfig } from "@/lib/runtime-config/native"
const REFRESH_LEAD_MS = 5 * 60 * 1000
const REFRESH_CHECK_MS = 60 * 1000
@ -34,12 +42,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[]
)
const native = useNativeRuntime()
const syncSession = useCallback(async () => {
if (native) {
const token = await ensureNativeAccessToken()
if (token) return true
logout()
return false
}
const data = await fetchSession()
if (data && applySession(data)) return true
logout()
return false
}, [applySession, logout])
}, [applySession, logout, native])
useEffect(() => {
let cancelled = false
@ -50,6 +66,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return
}
if (native) {
// Native: session lives in the OS secure store; no server selected yet
// means the user must run the server picker first.
await hydrateNativeRuntimeConfig()
if (cancelled) return
if (getRuntimeConfig()) {
const ok = await loadNativeSession()
if (!cancelled && ok) {
await ensureNativeAccessToken()
}
}
if (!cancelled) setReady(true)
return
}
const data = await fetchSession()
if (cancelled) return
@ -80,7 +111,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true
}
}, [applySession, logout, pathname])
}, [applySession, logout, pathname, native])
useEffect(() => {
if (!ready || !isOidcConfigured()) return
@ -100,6 +131,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!ready || !isOidcConfigured()) return
if (isPublicPath(pathname)) return
if (isAuthenticated()) return
// NativeAuthGate shows picker/login inline — avoid fighting redirects.
if (native) return
let cancelled = false
void syncSession().then((ok) => {
@ -120,9 +153,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
export function useAuthLogout() {
const logout = useAuthStore((s) => s.logout)
const router = useRouter()
const native = useNativeRuntime()
return async () => {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
if (native) {
// Unregister the push device token before dropping the session.
try {
const { unregisterPushOnLogout } = await import("@/lib/native/push")
await unregisterPushOnLogout()
} catch {
/* best effort */
}
await nativeLogout()
} else {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
}
logout()
if (typeof window !== "undefined") {
localStorage.removeItem(AUTH_STORAGE_KEY)

View File

@ -10,8 +10,14 @@ export function LoginChrome({ children }: { children: React.ReactNode }) {
}, [])
return (
<div className="ultimail-login relative flex min-h-dvh flex-col bg-app-canvas">
{children}
<div className="ultimail-login relative flex min-h-dvh flex-col">
<div className="ultimail-login-backdrop" aria-hidden>
<div className="ultimail-login-orb ultimail-login-orb--a" />
<div className="ultimail-login-orb ultimail-login-orb--b" />
<div className="ultimail-login-orb ultimail-login-orb--c" />
<div className="ultimail-login-aurora" />
</div>
<div className="relative z-10 flex min-h-dvh flex-col">{children}</div>
</div>
)
}

View File

@ -0,0 +1,80 @@
"use client"
import { Sparkles } from "lucide-react"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from "@/components/ui/card"
import { cn } from "@/lib/utils"
const LOGIN_CARD_CLASS = cn(
"w-full gap-4 border-0 bg-transparent px-4 py-6 shadow-none",
"sm:gap-5 sm:rounded-none sm:px-8 sm:py-8",
"sm:text-card-foreground sm:dark:text-mail-text sm:shadow-none"
)
type LoginFormProps = {
loginHref: string
signupHref: string
error: string | null
}
export function LoginForm({ loginHref, signupHref, error }: LoginFormProps) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-4">
<div className="ultimail-login-card-frame mx-auto w-full max-w-sm">
<Card className={LOGIN_CARD_CLASS}>
<CardHeader className="gap-4 px-0 text-center sm:px-0">
<div className="flex flex-col items-center gap-3 py-4">
<img
src="/ultisuite-mark.svg"
alt=""
width={72}
height={72}
draggable={false}
className="h-16 w-16 select-none"
aria-hidden
/>
<span className="text-2xl font-bold tracking-tight">
Ulti<span className="landing-gradient-text">Suite</span>
</span>
</div>
<CardDescription>
Connecte-toi avec ton compte UltiSpace pour accéder à ta suite.
</CardDescription>
{error ? (
<p className="text-sm text-destructive" role="alert">
{decodeURIComponent(error)}
</p>
) : null}
</CardHeader>
<CardContent className="flex justify-center px-0 sm:px-0">
<div className="ultimail-login-connect-border">
<a href={loginHref} className="ultimail-login-connect-btn">
<Sparkles className="size-4 shrink-0" strokeWidth={2} aria-hidden />
Se connecter avec UltiSpace
</a>
</div>
</CardContent>
<CardFooter className="px-0 sm:px-0">
<p className="w-full text-center text-sm text-muted-foreground">
Pas encore de compte ?{" "}
<a
className="font-medium text-primary underline"
href={signupHref}
suppressHydrationWarning
>
Créer un compte
</a>
</p>
</CardFooter>
</Card>
</div>
</div>
)
}

View File

@ -1,7 +1,7 @@
"use client"
import { useCallback, useEffect } from "react"
import { usePathname } from "next/navigation"
import { usePathname, useRouter } from "next/navigation"
import { Icon } from "@iconify/react"
import {
AlertDialog,
@ -16,6 +16,8 @@ import { Button } from "@/components/ui/button"
import { useSessionGuardStore } from "@/lib/auth/session-guard-store"
import { tryRefreshSession } from "@/lib/auth/session-sync"
import { isAuthPublicPath } from "@/lib/auth/public-paths"
import { useNativeRuntime } from "@/lib/platform"
import { useAuthStore } from "@/lib/api/auth-store"
import { cn } from "@/lib/utils"
function isPublicPath(pathname: string) {
@ -24,10 +26,15 @@ function isPublicPath(pathname: string) {
export function SessionGuard() {
const pathname = usePathname()
const router = useRouter()
const native = useNativeRuntime()
const authenticated = useAuthStore((s) => Boolean(s.accessToken))
const status = useSessionGuardStore((s) => s.status)
const returnTo = pathname.startsWith("/") ? pathname : "/mail/inbox"
const loginHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
const loginHref = native
? `/login?returnTo=${encodeURIComponent(returnTo)}`
: `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
const retrySession = useCallback(async () => {
if (typeof navigator !== "undefined" && !navigator.onLine) return
@ -46,6 +53,8 @@ export function SessionGuard() {
}, [status, retrySession])
if (isPublicPath(pathname)) return null
// NativeAuthGate handles login — don't block touches with an expired modal.
if (native && !authenticated) return null
return (
<>
@ -85,7 +94,16 @@ export function SessionGuard() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction asChild>
<a href={loginHref}>Se reconnecter</a>
{native ? (
<Button
type="button"
onClick={() => router.replace(loginHref)}
>
Se reconnecter
</Button>
) : (
<a href={loginHref}>Se reconnecter</a>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@ -0,0 +1,36 @@
"use client"
import { useEffect } from "react"
import { usePathname } from "next/navigation"
import { useTheme } from "next-themes"
import {
applyMailBackgroundDom,
clearMailBackgroundDom,
} from "@/lib/mail-settings/mail-background-dom"
import { useClientThemeStore } from "@/lib/stores/client-theme-store"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { isMailAppPath } from "@/lib/suite/mail-route"
/** Applique thème clair/sombre/système (client) et fond décoratif mail. */
export function ClientThemeApplier() {
const pathname = usePathname()
const themeMode = useClientThemeStore((s) => s.themeMode)
const backgroundId = useMailSettingsStore((s) => s.backgroundId)
const { theme, setTheme } = useTheme()
useEffect(() => {
if (!theme || theme === themeMode) return
setTheme(themeMode)
}, [themeMode, theme, setTheme])
useEffect(() => {
if (!isMailAppPath(pathname)) {
clearMailBackgroundDom()
return
}
applyMailBackgroundDom(backgroundId)
return () => clearMailBackgroundDom()
}, [backgroundId, pathname])
return null
}

View File

@ -10,7 +10,7 @@ import {
resolveAuthentikTheme,
type AuthentikUserSettingsTab,
} from "@/lib/auth/authentik-user-url"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { useClientThemeStore } from "@/lib/stores/client-theme-store"
type CompteAuthentikPanelProps = {
title: string
@ -29,7 +29,7 @@ export function CompteAuthentikPanel({
actionLabel,
icon,
}: CompteAuthentikPanelProps) {
const themeMode = useMailSettingsStore((s) => s.themeMode)
const themeMode = useClientThemeStore((s) => s.themeMode)
const { resolvedTheme } = useTheme()
const authentikTheme = resolveAuthentikTheme(themeMode, resolvedTheme)

View File

@ -31,6 +31,13 @@ export const DEMO_USER = {
email: "camille@demo.ulti",
}
/** Fixed calendar day for demo relative times ("09:42", "Mer.", …). Wednesday 2026-06-17. */
export const DEMO_REFERENCE_DATE_UTC = {
year: 2026,
month: 5,
day: 17,
} as const
export const DEMO_EMAILS: DemoEmail[] = [
{
id: "m1",

View File

@ -11,8 +11,13 @@ import { DriveSearchBreadcrumb } from "@/components/drive/drive-search-breadcrum
import type { DriveFileInfo } from "@/lib/api/types"
import type { DriveSearchState } from "@/lib/drive/drive-search"
import type { DriveView } from "@/lib/drive/drive-url"
import { DRIVE_CARD_PAD_X, DRIVE_FILTER_CONTENT_GAP } from "@/lib/drive/drive-chrome-classes"
import {
DRIVE_CARD_PAD_X,
DRIVE_FILTER_CONTENT_GAP,
DRIVE_FILTER_LIST_CONTENT_GAP,
} from "@/lib/drive/drive-chrome-classes"
import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
import { useDriveSettingsStore } from "@/lib/stores/drive-settings-store"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
import { cn } from "@/lib/utils"
@ -41,7 +46,10 @@ export function DriveBrowserChrome({
}) {
const selectedPaths = useDriveUIStore((s) => s.selectedPaths)
const clearSelection = useDriveUIStore((s) => s.clearSelection)
const viewMode = useDriveSettingsStore((s) => s.viewMode)
const mutations = useDriveMutations()
const filterContentGap =
viewMode === "list" ? DRIVE_FILTER_LIST_CONTENT_GAP : DRIVE_FILTER_CONTENT_GAP
const selectedTargets = useMemo(
() => items.filter((f) => selectedPaths.has(f.path)),
[items, selectedPaths]
@ -65,7 +73,7 @@ export function DriveBrowserChrome({
}
return (
<div className={cn("shrink-0", DRIVE_FILTER_CONTENT_GAP)}>
<div className={cn("shrink-0", filterContentGap)}>
<div className={cn("flex min-h-12 shrink-0 items-center justify-between gap-4 py-1", DRIVE_CARD_PAD_X)}>
<div className="min-w-0 flex-1">
{showSearchBreadcrumb ? (

View File

@ -109,7 +109,7 @@ export function FileBrowser({
<div>
<div
className={cn(
"sticky top-0 z-10 hidden items-center gap-3 border-b border-border bg-mail-surface py-2 text-xs font-medium text-muted-foreground sm:flex",
"sticky top-0 z-10 hidden items-center gap-3 border-b border-border bg-mail-surface pb-2 pt-1 text-xs font-medium text-muted-foreground sm:flex",
DRIVE_CARD_PAD_X
)}
aria-hidden

View File

@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { apiClient } from "@/lib/api/client"
import { getOnlyOfficeUrl } from "@/lib/runtime-config"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import { OnlyOfficeMount } from "@/components/drive/onlyoffice-mount"
@ -83,7 +84,7 @@ export function OfficeEditor({
if (cancelled) return
instanceSeq.current += 1
setConfig(res.config)
setServerUrl(res.serverUrl || process.env.NEXT_PUBLIC_ONLYOFFICE_URL || "")
setServerUrl(res.serverUrl || getOnlyOfficeUrl() || "")
setEditorId(`ultidrive-editor-${instanceSeq.current}`)
} catch {
if (!cancelled) setError("Impossible de charger léditeur.")

View File

@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { getOnlyOfficeUrl } from "@/lib/runtime-config"
import { ArrowLeft } from "lucide-react"
import { OnlyOfficeMount } from "@/components/drive/onlyoffice-mount"
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
@ -88,7 +89,7 @@ export function PublicOfficeEditor({
if (cancelled) return
instanceSeq.current += 1
setConfig(data.config)
setServerUrl(data.serverUrl || process.env.NEXT_PUBLIC_ONLYOFFICE_URL || "")
setServerUrl(data.serverUrl || getOnlyOfficeUrl() || "")
setEditorId(`ultidrive-public-editor-${instanceSeq.current}`)
if (data.mode === "edit" || data.mode === "view") {
setResolvedMode(data.mode)

View File

@ -30,6 +30,7 @@ import {
DRIVE_CARD_PAD_X,
DRIVE_CARD_SCROLL_PT,
DRIVE_FILTER_CONTENT_GAP,
DRIVE_FILTER_LIST_CONTENT_GAP,
} from "@/lib/drive/drive-chrome-classes"
import { cn } from "@/lib/utils"
import { nextUntitledName } from "@/lib/drive/drive-default-name"
@ -56,6 +57,9 @@ export function PublicShareFolderView({
const sortField = useDriveSettingsStore((s) => s.sortField)
const sortDir = useDriveSettingsStore((s) => s.sortDir)
const folderPlacement = useDriveSettingsStore((s) => s.folderPlacement)
const viewMode = useDriveSettingsStore((s) => s.viewMode)
const filterContentGap =
viewMode === "list" ? DRIVE_FILTER_LIST_CONTENT_GAP : DRIVE_FILTER_CONTENT_GAP
const filters = useDriveFiltersStore()
const uploadInputRef = useRef<HTMLInputElement>(null)
@ -154,7 +158,7 @@ export function PublicShareFolderView({
return (
<div className={DRIVE_BROWSER_CARD_CLASS}>
<div className={cn("shrink-0", DRIVE_FILTER_CONTENT_GAP)}>
<div className={cn("shrink-0", filterContentGap)}>
<div
className={cn(
"flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-3 py-2",

View File

@ -11,6 +11,7 @@ import {
type SuiteSplashApp,
} from "@/lib/suite/suite-app-splash"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
import { IS_MOBILE_BUILD, useNativeRuntime } from "@/lib/platform"
import { cn } from "@/lib/utils"
const SPLASH_VISIBLE_MS = 1750
@ -21,16 +22,21 @@ export function FirstLaunchSplash({
}: {
children: React.ReactNode
}) {
const native = useNativeRuntime()
const pathname = usePathname()
const [activeApp, setActiveApp] = useState<SuiteSplashApp | null>(() =>
typeof window === "undefined"
? null
: shouldShowSuiteSplash(window.location.pathname)
)
const skipSplash = IS_MOBILE_BUILD || native
const [activeApp, setActiveApp] = useState<SuiteSplashApp | null>(null)
const [isHiding, setIsHiding] = useState(false)
const [isComplete, setIsComplete] = useState(() => activeApp === null)
const [isComplete, setIsComplete] = useState(true)
useLayoutEffect(() => {
if (skipSplash) {
setActiveApp(null)
setIsComplete(true)
setIsHiding(false)
document.documentElement.dataset.splashSeen = "1"
return
}
const nextApp = shouldShowSuiteSplash(pathname)
const root = document.documentElement
root.dataset.splashApp = suiteSplashAppFromPath(pathname) ?? ""
@ -38,7 +44,7 @@ export function FirstLaunchSplash({
setActiveApp(nextApp)
setIsComplete(nextApp === null)
setIsHiding(false)
}, [pathname])
}, [pathname, skipSplash])
useEffect(() => {
if (!activeApp) return
@ -65,7 +71,7 @@ export function FirstLaunchSplash({
return (
<>
{children}
{!isComplete && config ? (
{!skipSplash && !isComplete && config ? (
<div
className={cn("app-first-launch-splash", isHiding && "app-first-launch-splash--hide")}
role="status"

View File

@ -1,6 +1,7 @@
"use client"
import { useRef, useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import {
Dialog,
DialogContent,
@ -8,12 +9,12 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Info } from "lucide-react"
import { parseContactFile } from "@/lib/contacts/import-parsers"
import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations"
import { fullContactToApiContact } from "@/lib/api/adapters"
import { Info, Smartphone } from "lucide-react"
import { parseContactFile, type ContactImportInput } from "@/lib/contacts/import-parsers"
import { bulkImportContacts, importInputToBulk } from "@/lib/api/contacts-bulk-import"
import { deviceContactsAvailable, fetchDeviceContacts } from "@/lib/native/contacts"
import { invalidateContactListCache } from "@/lib/api/contact-list-cache"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import type { FullContact } from "@/lib/contacts/types"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
@ -30,18 +31,20 @@ interface ImportDialogProps {
export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
const fileRef = useRef<HTMLInputElement>(null)
const createContactMutation = useCreateContact()
const queryClient = useQueryClient()
const { bookId } = useContactsList()
const [pendingFile, setPendingFile] = useState<File | null>(null)
const [previewCount, setPreviewCount] = useState(0)
const [error, setError] = useState<string | null>(null)
const [importing, setImporting] = useState(false)
const [result, setResult] = useState<string | null>(null)
function resetState() {
setPendingFile(null)
setPreviewCount(0)
setError(null)
setImporting(false)
setResult(null)
if (fileRef.current) fileRef.current.value = ""
}
@ -73,34 +76,26 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
}
}
async function handleImport() {
if (!pendingFile || previewCount === 0) return
async function runImport(parsed: ContactImportInput[]) {
if (parsed.length === 0) {
setError("Aucun contact à importer.")
return
}
setImporting(true)
setError(null)
setResult(null)
try {
const parsed = await parseContactFile(pendingFile)
if (parsed.length === 0) {
setError("Aucun contact importé.")
return
const { created, failed } = await bulkImportContacts(
bookId,
parsed.map(importInputToBulk)
)
invalidateContactListCache(bookId)
void queryClient.invalidateQueries({ queryKey: ["contacts", bookId] })
if (failed.length > 0) {
setResult(`${created} importé(s), ${failed.length} en échec.`)
} else {
handleOpenChange(false)
}
for (const partial of parsed) {
const fullContact: FullContact = {
id: crypto.randomUUID(),
createdAt: Date.now(),
updatedAt: Date.now(),
...partial,
firstName: partial.firstName ?? "",
lastName: partial.lastName ?? "",
emails: partial.emails ?? [],
phones: partial.phones ?? [],
}
createContactMutation.mutate({
bookId,
contact: fullContactToApiContact(fullContact),
})
}
handleOpenChange(false)
} catch {
setError("L'import a échoué. Vérifiez le format du fichier.")
} finally {
@ -108,6 +103,33 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
}
}
async function handleImport() {
if (!pendingFile || previewCount === 0) return
try {
const parsed = await parseContactFile(pendingFile)
await runImport(parsed)
} catch {
setError("L'import a échoué. Vérifiez le format du fichier.")
}
}
async function handleDeviceImport() {
setImporting(true)
setError(null)
try {
const parsed = await fetchDeviceContacts()
await runImport(parsed)
} catch (err) {
const msg = err instanceof Error ? err.message : "device_error"
setError(
msg === "contacts_unavailable" || msg.includes("denied")
? "Accès aux contacts du téléphone refusé."
: "Impossible de lire les contacts du téléphone."
)
setImporting(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
@ -128,6 +150,19 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
Sélectionner un fichier
</Button>
{deviceContactsAvailable() && (
<Button
type="button"
variant="outline"
onClick={() => void handleDeviceImport()}
disabled={importing}
className="w-full gap-2"
>
<Smartphone className="h-4 w-4" />
Importer depuis le téléphone
</Button>
)}
<input
ref={fileRef}
type="file"
@ -144,6 +179,7 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
</p>
)}
{result && <p className="text-sm text-amber-600">{result}</p>}
{error && <p className="text-sm text-red-600">{error}</p>}
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>

View File

@ -15,7 +15,6 @@ import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
type PersistedSettings = Pick<
ReturnType<typeof useMailSettingsStore.getState>,
| "density"
| "themeMode"
| "backgroundId"
| "inboxSort"
| "readingPane"
@ -29,7 +28,6 @@ type PersistedSettings = Pick<
function pickPersisted(state: ReturnType<typeof useMailSettingsStore.getState>): PersistedSettings {
return {
density: state.density,
themeMode: state.themeMode,
backgroundId: state.backgroundId,
inboxSort: state.inboxSort,
readingPane: state.readingPane,
@ -47,7 +45,6 @@ function diffPersisted(
): Partial<PersistedSettings> {
const changed: Partial<PersistedSettings> = {}
if (prev.density !== next.density) changed.density = next.density
if (prev.themeMode !== next.themeMode) changed.themeMode = next.themeMode
if (prev.backgroundId !== next.backgroundId) changed.backgroundId = next.backgroundId
if (prev.inboxSort !== next.inboxSort) changed.inboxSort = next.inboxSort
if (prev.readingPane !== next.readingPane) changed.readingPane = next.readingPane

View File

@ -1,53 +0,0 @@
"use client"
import { useEffect } from "react"
import { usePathname } from "next/navigation"
import { useTheme } from "next-themes"
import {
applyMailBackgroundDom,
clearMailBackgroundDom,
} from "@/lib/mail-settings/mail-background-dom"
import { isDemoPath } from "@/lib/demo/demo-route"
import { useDemoThemeStore } from "@/lib/demo/demo-theme-store"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { isMailAppPath } from "@/lib/suite/mail-route"
import type { MailThemeMode } from "@/lib/mail-settings/types"
function resolveAppliedThemeMode(
pathname: string | null,
mailThemeMode: MailThemeMode,
demoThemeMode: MailThemeMode,
): MailThemeMode {
if (isDemoPath(pathname)) return demoThemeMode
return mailThemeMode
}
/** Applique thème clair/sombre/système et fond décoratif sur le document. */
export function MailThemeApplier() {
const pathname = usePathname()
const mailThemeMode = useMailSettingsStore((s) => s.themeMode)
const demoThemeMode = useDemoThemeStore((s) => s.themeMode)
const appliedThemeMode = resolveAppliedThemeMode(
pathname,
mailThemeMode,
demoThemeMode,
)
const backgroundId = useMailSettingsStore((s) => s.backgroundId)
const { theme, setTheme } = useTheme()
useEffect(() => {
if (!theme || theme === appliedThemeMode) return
setTheme(appliedThemeMode)
}, [appliedThemeMode, theme, setTheme])
useEffect(() => {
if (!isMailAppPath(pathname)) {
clearMailBackgroundDom()
return
}
applyMailBackgroundDom(backgroundId)
return () => clearMailBackgroundDom()
}, [backgroundId, pathname])
return null
}

View File

@ -13,6 +13,14 @@ export type LandingApp = {
accent: string
}
/** Onglet démo (#demo) associé à une app (dock hero, visiteur non connecté). */
export const LANDING_APP_DEMO_TAB: Partial<Record<string, string>> = {
"/mail": "mail",
"/drive": "drive",
"/contacts": "contacts",
"/agenda": "agenda",
}
export const LANDING_APPS: LandingApp[] = [
{
name: "Ultimail",

View File

@ -58,10 +58,26 @@ const DEMO_TABS: DemoTab[] = [
},
]
export function LandingDemoSection() {
type LandingDemoSectionProps = {
activeTab?: string
onActiveTabChange?: (tabId: string) => void
/** Incrémenté depuis le hero pour forcer le montage des iframes avant scroll. */
revealNonce?: number
}
export function LandingDemoSection({
activeTab: controlledTab,
onActiveTabChange,
revealNonce = 0,
}: LandingDemoSectionProps = {}) {
const sectionRef = useRef<HTMLElement>(null)
const [visible, setVisible] = useState(false)
const [activeTab, setActiveTab] = useState(DEMO_TABS[0].id)
const [internalTab, setInternalTab] = useState(DEMO_TABS[0].id)
const activeTab = controlledTab ?? internalTab
const setActiveTab = (tabId: string) => {
if (onActiveTabChange) onActiveTabChange(tabId)
else setInternalTab(tabId)
}
/** Onglets dont l'iframe a été montée (état conservé au changement d'onglet). */
const [mounted, setMounted] = useState<Record<string, boolean>>({})
/** Incrément par onglet pour réinitialiser la démo (remount iframe). */
@ -83,6 +99,12 @@ export function LandingDemoSection() {
return () => observer.disconnect()
}, [visible])
useEffect(() => {
if (!revealNonce) return
setVisible(true)
setMounted((prev) => ({ ...prev, [activeTab]: true }))
}, [revealNonce, activeTab])
useEffect(() => {
if (!visible) return
setMounted((prev) => (prev[activeTab] ? prev : { ...prev, [activeTab]: true }))

View File

@ -3,122 +3,73 @@
import Link from "next/link"
import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
import { LANDING_APPS } from "@/components/landing/landing-data"
import { LANDING_APPS, LANDING_APP_DEMO_TAB } from "@/components/landing/landing-data"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
import { cn } from "@/lib/utils"
function HeroDock() {
function HeroDock({
authenticated,
onOpenDemo,
}: {
authenticated: boolean
onOpenDemo: (demoTabId: string | null) => void
}) {
const apps = LANDING_APPS.filter((app) => app.href)
return (
<div className="flex flex-wrap items-center justify-center gap-3 sm:gap-4">
{apps.map((app, index) => (
<Link
key={app.name}
href={app.href!}
title={app.name}
className="landing-dock-tile landing-glass group flex size-14 items-center justify-center rounded-2xl transition-transform hover:scale-110 sm:size-16"
style={{ "--float-delay": `${index * 0.55}s` } as React.CSSProperties}
>
{apps.map((app, index) => {
const tileClass =
"landing-dock-tile landing-glass group flex size-14 items-center justify-center rounded-2xl transition-transform hover:scale-110 sm:size-16"
const tileStyle = { "--float-delay": `${index * 0.55}s` } as React.CSSProperties
const icon = (
<img
src={app.icon}
alt={app.name}
className="size-8 object-contain transition-transform group-hover:scale-110 sm:size-9"
draggable={false}
/>
</Link>
))}
)
if (authenticated) {
return (
<Link
key={app.name}
href={app.href!}
title={app.name}
className={tileClass}
style={tileStyle}
>
{icon}
</Link>
)
}
return (
<button
key={app.name}
type="button"
title={app.name}
className={tileClass}
style={tileStyle}
onClick={() => {
const demoTab = app.href ? (LANDING_APP_DEMO_TAB[app.href] ?? null) : null
onOpenDemo(demoTab)
}}
>
{icon}
</button>
)
})}
</div>
)
}
/** Fenêtre « produit » stylisée (pas un vrai screenshot — du pur CSS). */
function HeroPreview() {
const rows = [
{ from: "Conseil d'administration", subject: "Ordre du jour — revue Q3", time: "09:12", unread: true },
{ from: "UltiAI", subject: "Résumé de vos 12 mails non lus", time: "08:47", ai: true },
{ from: "Marie Laurent", subject: "Spécifications produit v2 (UltiDocs)", time: "08:30" },
{ from: "Infra", subject: "Sauvegarde hebdomadaire effectuée ✓", time: "07:58" },
]
return (
<div
className="landing-glass-strong relative mx-auto w-full max-w-3xl rounded-2xl p-2 shadow-[0_40px_90px_-40px_rgba(30,40,90,0.45)]"
aria-hidden
>
<div className="flex items-center gap-1.5 px-3 py-2">
<span className="size-2.5 rounded-full bg-[#ff5f57]" />
<span className="size-2.5 rounded-full bg-[#febc2e]" />
<span className="size-2.5 rounded-full bg-[#28c840]" />
<div className="ml-3 flex h-6 flex-1 items-center rounded-full bg-[var(--landing-chip)] px-3 text-[11px] text-[var(--landing-muted)]">
suite.votre-domaine.fr/mail
</div>
</div>
<div className="flex overflow-hidden rounded-xl border border-[var(--landing-line)]">
<div className="hidden w-40 shrink-0 flex-col gap-1 border-r border-[var(--landing-line)] bg-[var(--landing-card)] p-3 sm:flex">
<div className="landing-cta--primary landing-cta mb-2 h-8 w-full rounded-full text-xs">
Nouveau message
</div>
{["Boîte de réception", "Favoris", "Programmés", "Brouillons"].map(
(label, i) => (
<div
key={label}
className={cn(
"flex items-center justify-between rounded-full px-3 py-1.5 text-xs",
i === 0
? "bg-[var(--landing-chip)] font-semibold text-[var(--landing-chip-fg)]"
: "text-[var(--landing-muted)]"
)}
>
<span>{label}</span>
{i === 0 ? <span>12</span> : null}
</div>
)
)}
</div>
<div className="flex-1 divide-y divide-[var(--landing-line)] bg-[var(--landing-card-strong)]">
{rows.map((row) => (
<div key={row.subject} className="flex items-center gap-3 px-4 py-3">
<span
className={cn(
"size-2 shrink-0 rounded-full",
row.unread ? "bg-[var(--landing-glow-a)]" : "bg-transparent"
)}
/>
<span
className={cn(
"w-32 shrink-0 truncate text-xs sm:w-40 sm:text-[13px]",
row.unread ? "font-semibold" : "text-[var(--landing-muted)]"
)}
>
{row.from}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--landing-muted)] sm:text-[13px]">
{row.ai ? (
<span className="mr-1.5 inline-flex items-center gap-1 rounded-full bg-[var(--landing-chip)] px-1.5 py-px text-[10px] font-semibold text-[var(--landing-chip-fg)]">
<Icon icon="mdi:creation-outline" className="size-3" />
IA
</span>
) : null}
{row.subject}
</span>
<span className="shrink-0 text-[11px] text-[var(--landing-muted)]">
{row.time}
</span>
</div>
))}
</div>
</div>
</div>
)
}
export function LandingHero() {
export function LandingHero({ onOpenDemo }: { onOpenDemo: (demoTabId: string | null) => void }) {
const identity = useChromeIdentity()
return (
<section className="relative px-4 pb-20 pt-14 sm:px-6 sm:pt-20">
<section className="relative px-4 pb-10 pt-14 sm:px-6 sm:pt-20">
<div className="mx-auto flex w-full max-w-5xl flex-col items-center gap-8 text-center">
<LandingReveal>
<span className="landing-glass inline-flex items-center gap-2.5 rounded-full px-4 py-1.5 text-xs font-medium text-[var(--landing-muted)] sm:text-sm">
@ -198,11 +149,7 @@ export function LandingHero() {
</LandingReveal>
<LandingReveal delay={0.32} className="w-full">
<HeroDock />
</LandingReveal>
<LandingReveal delay={0.4} className="w-full pt-6">
<HeroPreview />
<HeroDock authenticated={Boolean(identity)} onOpenDemo={onOpenDemo} />
</LandingReveal>
</div>
</section>

View File

@ -1,10 +1,9 @@
"use client"
import { useRef, useState } from "react"
import { useCallback, useRef, useState } from "react"
import { LandingDemoSection } from "@/components/landing/landing-demo"
import { LandingHeader } from "@/components/landing/landing-header"
import { LandingHero } from "@/components/landing/landing-hero"
import { LandingThemeApplier } from "@/components/landing/landing-theme-applier"
import {
LandingAppsSection,
LandingFeaturesSection,
@ -16,6 +15,16 @@ import {
export function LandingPage() {
const scrollRef = useRef<HTMLDivElement>(null)
const [scrolled, setScrolled] = useState(false)
const [demoActiveTab, setDemoActiveTab] = useState("mail")
const [demoRevealNonce, setDemoRevealNonce] = useState(0)
const openDemo = useCallback((tabId: string | null) => {
if (tabId) setDemoActiveTab(tabId)
setDemoRevealNonce((n) => n + 1)
requestAnimationFrame(() => {
document.getElementById("demo")?.scrollIntoView({ behavior: "smooth", block: "start" })
})
}, [])
return (
<div
@ -26,7 +35,6 @@ export function LandingPage() {
setScrolled((prev) => (top > 8 ? true : top <= 2 ? false : prev))
}}
>
<LandingThemeApplier />
<div className="landing-backdrop" aria-hidden>
<div className="landing-orb landing-orb--a" />
<div className="landing-orb landing-orb--b" />
@ -36,9 +44,13 @@ export function LandingPage() {
<div className="relative z-10 flex min-h-full flex-col">
<LandingHeader scrolled={scrolled} />
<main className="flex-1">
<LandingHero />
<LandingHero onOpenDemo={openDemo} />
<LandingIntegrationsSection />
<LandingDemoSection />
<LandingDemoSection
activeTab={demoActiveTab}
onActiveTabChange={setDemoActiveTab}
revealNonce={demoRevealNonce}
/>
<LandingAppsSection />
<LandingFeaturesSection />
<LandingSovereigntySection />

View File

@ -1,6 +1,11 @@
"use client"
import { useEffect, useRef, useState, type ReactNode } from "react"
import {
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react"
import { cn } from "@/lib/utils"
interface LandingRevealProps {
@ -11,6 +16,11 @@ interface LandingRevealProps {
as?: "div" | "section" | "li" | "span"
}
function isInRevealViewport(node: HTMLElement, margin = 36): boolean {
const rect = node.getBoundingClientRect()
return rect.top < window.innerHeight - margin && rect.bottom > margin
}
/** Révèle son contenu à l'entrée dans le viewport (une seule fois). */
export function LandingReveal({
children,
@ -19,15 +29,25 @@ export function LandingReveal({
as: Tag = "div",
}: LandingRevealProps) {
const ref = useRef<HTMLElement | null>(null)
const [revealed, setRevealed] = useState(false)
const [mounted, setMounted] = useState(false)
const [revealed, setRevealed] = useState(true)
useEffect(() => {
useLayoutEffect(() => {
setMounted(true)
const node = ref.current
if (!node || revealed) return
if (!node) return
if (typeof IntersectionObserver === "undefined") {
setRevealed(true)
return
}
if (isInRevealViewport(node)) {
setRevealed(true)
return
}
setRevealed(false)
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
@ -39,13 +59,16 @@ export function LandingReveal({
)
observer.observe(node)
return () => observer.disconnect()
}, [revealed])
}, [])
return (
<Tag
ref={ref as never}
className={cn("landing-reveal", className)}
data-revealed={revealed}
data-revealed={
mounted ? (revealed ? "true" : "false") : undefined
}
suppressHydrationWarning
style={delay ? ({ "--reveal-delay": `${delay}s` } as React.CSSProperties) : undefined}
>
{children}

View File

@ -268,7 +268,7 @@ function IntegrationsTrack() {
export function LandingIntegrationsSection() {
return (
<section className="px-0 py-10">
<section className="px-0 pt-4 pb-10">
<LandingReveal className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<p className="text-center text-xs font-semibold uppercase tracking-widest text-[var(--landing-muted)]">
S'intègre avec vos standards ouverts

View File

@ -1,20 +0,0 @@
"use client"
import { useEffect } from "react"
/** Landing toujours en thème système, indépendamment des réglages Ultimail. */
export function LandingThemeApplier() {
useEffect(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)")
const apply = () => {
document.documentElement.classList.toggle("dark", media.matches)
}
apply()
media.addEventListener("change", apply)
return () => media.removeEventListener("change", apply)
}, [])
return null
}

View File

@ -0,0 +1,103 @@
"use client"
import { Component, useEffect, useState, type ReactNode } from "react"
import { ClientThemeShell } from "@/components/suite/client-theme-shell"
import { runThemeInit } from "@/components/theme-init-script"
import { FirstLaunchSplash } from "@/components/first-launch-splash"
import { QueryProvider } from "@/lib/api/query-provider"
import { AuthProvider } from "@/components/auth/auth-provider"
import { SessionGuard } from "@/components/auth/session-guard"
import { NativeBridgeProvider } from "@/components/mobile/native-bridge-provider"
import { NativeAuthGate } from "@/components/mobile/native-auth-gate"
import { NativeShellChrome } from "@/components/mobile/native-shell-chrome"
import { MailToaster } from "@/components/gmail/mail-toaster"
function MobileBootScreen({ hint }: { hint?: string }) {
return (
<div className="fixed inset-0 flex h-dvh flex-col items-center justify-center gap-3 bg-background px-6 text-center">
<img
src="/ultisuite-mark.svg"
alt=""
width={64}
height={64}
className="h-14 w-14 select-none"
aria-hidden
/>
<p className="text-sm text-muted-foreground">UltiMail</p>
{hint ? (
<p className="text-xs text-muted-foreground/80">{hint}</p>
) : null}
</div>
)
}
class MobileErrorBoundary extends Component<
{ children: ReactNode },
{ error: Error | null }
> {
state = { error: null as Error | null }
static getDerivedStateFromError(error: Error) {
return { error }
}
render() {
if (this.state.error) {
return (
<MobileBootScreen
hint={this.state.error.message || "Erreur au démarrage."}
/>
)
}
return this.props.children
}
}
/**
* Tauri mobile root. SSR renders a static boot screen; the interactive app
* mounts on the client so Android WebView never hydrates complex SSR trees.
*/
export function MobileLayoutRoot({ children }: { children: ReactNode }) {
const [clientReady, setClientReady] = useState(false)
const [bootHint, setBootHint] = useState<string | undefined>()
useEffect(() => {
setClientReady(true)
}, [])
useEffect(() => {
if (!clientReady) return
runThemeInit()
}, [clientReady])
useEffect(() => {
if (clientReady) return
const timer = window.setTimeout(() => {
setBootHint("Chargement JavaScript…")
}, 8_000)
return () => window.clearTimeout(timer)
}, [clientReady])
if (!clientReady) {
return <MobileBootScreen hint={bootHint} />
}
return (
<MobileErrorBoundary>
<ClientThemeShell>
<QueryProvider>
<AuthProvider>
<SessionGuard />
<NativeBridgeProvider>
<NativeShellChrome />
<NativeAuthGate>
<FirstLaunchSplash>{children}</FirstLaunchSplash>
</NativeAuthGate>
</NativeBridgeProvider>
</AuthProvider>
<MailToaster />
</QueryProvider>
</ClientThemeShell>
</MobileErrorBoundary>
)
}

View File

@ -0,0 +1,26 @@
"use client"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { appStartRoute } from "@/lib/platform"
/**
* Root entry for the native shells. The Tauri window opens directly on the
* per-app start route, but `/` is still generated by the static export; send
* any cold load there. The `?mail=` deep-link param is handled client-side.
*/
export function MobileRootRedirect() {
const router = useRouter()
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const mail = params.get("mail")
if (mail) {
router.replace(`/mail/inbox/message/${encodeURIComponent(mail)}`)
return
}
router.replace(appStartRoute())
}, [router])
return null
}

View File

@ -0,0 +1,128 @@
"use client"
import { useEffect, useRef, useState, type ReactNode } from "react"
import { usePathname } from "next/navigation"
import { isAuthPublicPath } from "@/lib/auth/public-paths"
import { ensureNativeAccessToken } from "@/lib/auth/native-session"
import { hydrateNativeRuntimeConfig } from "@/lib/runtime-config/native"
import { getRuntimeConfig } from "@/lib/runtime-config"
import { useNativeRuntime } from "@/lib/platform"
import { NativeLogin } from "@/components/mobile/native-login"
import { ServerPicker } from "@/components/mobile/server-picker"
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) => {
setTimeout(() => reject(new Error("timeout")), ms)
}),
])
}
type AuthScreen = "picker" | "login" | "none"
function initialAuthScreen(pathname: string): AuthScreen {
if (isAuthPublicPath(pathname)) return "none"
// SSR-safe: never read localStorage here (hydration mismatch breaks touch on Android).
return "picker"
}
function NativeAuthScreen({ children }: { children: ReactNode }) {
return (
<div className="native-auth-screen fixed inset-0 z-[10000] flex h-dvh flex-col overflow-auto bg-background touch-manipulation">
{children}
</div>
)
}
/**
* On native shells, show server picker / login inline (no client router redirects).
*/
export function NativeAuthGate({ children }: { children: ReactNode }) {
const native = useNativeRuntime()
const pathname = usePathname()
const booted = useRef(false)
const [authScreen, setAuthScreen] = useState<AuthScreen>(() =>
native ? initialAuthScreen(pathname) : "none"
)
const [returnTo, setReturnTo] = useState("/mail/inbox")
useEffect(() => {
if (!native) return
const dest = pathname.startsWith("/") ? pathname : "/mail/inbox"
setReturnTo(dest)
if (isAuthPublicPath(pathname)) {
setAuthScreen("none")
}
}, [native, pathname])
useEffect(() => {
if (!native || booted.current) return
let cancelled = false
async function bootstrap() {
try {
await withTimeout(hydrateNativeRuntimeConfig(), 3_000)
} catch {
/* localStorage fallback */
}
if (cancelled) return
if (isAuthPublicPath(pathname)) {
setAuthScreen("none")
return
}
if (!getRuntimeConfig()) {
setAuthScreen("picker")
return
}
let token: string | null = null
try {
token = await withTimeout(ensureNativeAccessToken(), 8_000)
} catch {
token = null
}
if (cancelled) return
setAuthScreen(token ? "none" : "login")
}
void bootstrap().finally(() => {
if (!cancelled) booted.current = true
})
return () => {
cancelled = true
}
}, [native, pathname])
if (!native) return children
if (isAuthPublicPath(pathname) || authScreen === "none") {
return children
}
if (authScreen === "picker") {
return (
<NativeAuthScreen>
<ServerPicker
onSelected={() => {
setAuthScreen("login")
}}
/>
</NativeAuthScreen>
)
}
return (
<NativeAuthScreen>
<NativeLogin returnTo={returnTo} />
</NativeAuthScreen>
)
}

View File

@ -0,0 +1,65 @@
"use client"
import { useEffect, useRef, type ReactNode } from "react"
import { useRouter } from "next/navigation"
import { useNativeRuntime } from "@/lib/platform"
import { listen } from "@/lib/native/bridge"
import { routeForDeepLink } from "@/lib/native/deep-links"
import {
takePendingShare,
stashShare,
shareLandingRoute,
} from "@/lib/native/share"
import { registerPushAfterLogin } from "@/lib/native/push"
import { useAuthStore } from "@/lib/api/auth-store"
/**
* Wires the native shell capabilities into the React app: deep-link routing,
* inbound share intake, and push-token registration after login. Inert on web.
*/
export function NativeBridgeProvider({ children }: { children: ReactNode }) {
const native = useNativeRuntime()
const router = useRouter()
const accessToken = useAuthStore((s) => s.accessToken)
const pushDone = useRef(false)
// Deep links -> client navigation.
useEffect(() => {
if (!native) return
let unlisten: (() => void) | null = null
void (async () => {
unlisten = await listen("ulti://deep-link", (payload) => {
const urls = Array.isArray(payload) ? payload : [payload]
for (const raw of urls) {
if (typeof raw !== "string") continue
const route = routeForDeepLink(raw)
if (route) router.push(route)
}
})
})()
return () => {
if (unlisten) unlisten()
}
}, [native, router])
// Inbound share content (cold start: drain the native queue).
useEffect(() => {
if (!native) return
void (async () => {
const payload = await takePendingShare()
if (payload) {
stashShare(payload)
router.push(shareLandingRoute())
}
})()
}, [native, router])
// Register the push device token once we have a session.
useEffect(() => {
if (!native || !accessToken || pushDone.current) return
pushDone.current = true
void registerPushAfterLogin()
}, [native, accessToken])
return <>{children}</>
}

View File

@ -0,0 +1,110 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
} from "@/components/ui/card"
import { ServerPicker } from "@/components/mobile/server-picker"
import { nativeStartLogin } from "@/lib/auth/native-auth"
import { getRuntimeConfig } from "@/lib/runtime-config"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
import { cn } from "@/lib/utils"
const CARD_CLASS = cn(
"w-full gap-4 border-0 bg-transparent px-4 py-6 shadow-none",
"sm:gap-5 sm:bg-card sm:px-8 sm:py-8"
)
/** Native login experience: server picker, then OIDC PKCE via the system browser. */
export function NativeLogin({ returnTo }: { returnTo: string }) {
const router = useRouter()
const [hasConfig, setHasConfig] = useState(() => Boolean(getRuntimeConfig()))
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!hasConfig) {
return <ServerPicker onSelected={() => setHasConfig(true)} />
}
const cfg = getRuntimeConfig()
async function login() {
setBusy(true)
setError(null)
try {
await nativeStartLogin({ returnTo })
router.replace(returnTo.startsWith("/") ? returnTo : "/mail/inbox")
} catch (err) {
setError(err instanceof Error ? err.message : "Connexion échouée.")
} finally {
setBusy(false)
}
}
return (
<div className="flex flex-1 flex-col items-center justify-center px-4">
<div className="mx-auto w-full max-w-sm">
<Card className={CARD_CLASS}>
<CardHeader className="gap-3 px-0 text-center">
<div className="flex flex-col items-center gap-3 py-2">
<img
src="/ultisuite-mark.svg"
alt=""
width={64}
height={64}
className="h-14 w-14 select-none"
aria-hidden
/>
<span className="text-xl font-bold tracking-tight">
Ulti<span className="text-[#4285F4]">Suite</span>
</span>
</div>
<CardDescription>
{cfg ? `Connecté à ${cfg.label}` : "Connecte-toi à ton compte Ulti."}
</CardDescription>
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</CardHeader>
<CardContent className="flex flex-col items-center gap-3 px-0">
<Button size="lg" className="w-full" disabled={busy} onClick={() => void login()}>
{busy ? "Connexion…" : "Se connecter"}
</Button>
<Button
size="sm"
variant="ghost"
className="w-full"
disabled={busy}
onClick={() => setHasConfig(false)}
>
Changer de serveur
</Button>
</CardContent>
<CardFooter className="px-0">
<p className="w-full text-center text-sm text-muted-foreground">
Pas encore de compte ?{" "}
<a
className="font-medium text-primary underline"
href={getAuthentikEnrollmentUrl()}
target="_blank"
rel="noreferrer"
>
Créer un compte
</a>
</p>
</CardFooter>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,33 @@
"use client"
import { useEffect, useLayoutEffect } from "react"
import { isTauriRuntime } from "@/lib/platform"
/** Native shell: safe-area CSS vars + html class for padding under status bar. */
export function NativeShellChrome() {
useLayoutEffect(() => {
if (!isTauriRuntime()) return
const root = document.documentElement
root.classList.add("native-shell")
// Android WebView often reports env(safe-area-inset-top) as 0 until Chromium 144+.
const probe = document.createElement("div")
probe.style.cssText =
"position:fixed;top:0;left:0;padding-top:env(safe-area-inset-top);visibility:hidden;pointer-events:none"
document.body.appendChild(probe)
const envTop = parseFloat(getComputedStyle(probe).paddingTop) || 0
probe.remove()
if (envTop < 1) {
root.style.setProperty("--native-safe-top", "28px")
}
return () => {
root.classList.remove("native-shell")
root.style.removeProperty("--native-safe-top")
}
}, [])
return null
}

View File

@ -0,0 +1,147 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card"
import {
deriveRuntimeConfig,
ultiSpaceOrigin,
type RuntimeConfig,
} from "@/lib/runtime-config"
import { persistNativeRuntimeConfig } from "@/lib/runtime-config/native"
import { cn } from "@/lib/utils"
const CARD_CLASS = cn(
"w-full gap-4 border-0 bg-transparent px-4 py-6 shadow-none",
"sm:gap-5 sm:bg-card sm:px-8 sm:py-8"
)
/**
* Pre-login server picker for the native shells: choose the hosted UltiSpace or
* point at a self-hosted instance. OIDC discovery derives the full runtime
* config, which is persisted to the shared secure store (cross-app SSO).
*/
export function ServerPicker({
onSelected,
}: {
onSelected: (cfg: RuntimeConfig) => void
}) {
const [mode, setMode] = useState<"choose" | "self">("choose")
const [url, setUrl] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function select(rawOrigin: string, label?: string) {
setLoading(true)
setError(null)
try {
const cfg = await deriveRuntimeConfig(rawOrigin, { label })
await persistNativeRuntimeConfig(cfg)
onSelected(cfg)
} catch (err) {
setError(err instanceof Error ? err.message : "Connexion impossible.")
} finally {
setLoading(false)
}
}
return (
<div className="flex flex-1 flex-col items-center justify-center px-4">
<div className="mx-auto w-full max-w-sm">
<Card className={CARD_CLASS}>
<CardHeader className="gap-3 px-0 text-center">
<div className="flex flex-col items-center gap-3 py-2">
<img
src="/ultisuite-mark.svg"
alt=""
width={64}
height={64}
className="h-14 w-14 select-none"
aria-hidden
/>
<span className="text-xl font-bold tracking-tight">
Ulti<span className="text-[#4285F4]">Suite</span>
</span>
</div>
<CardDescription>
Choisis ton serveur pour te connecter.
</CardDescription>
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-3 px-0">
{mode === "choose" ? (
<>
<Button
type="button"
size="lg"
className="w-full"
disabled={loading}
onClick={() => void select(ultiSpaceOrigin(), "UltiSpace")}
>
{loading ? "Connexion…" : "UltiSpace (hébergé)"}
</Button>
<Button
type="button"
size="lg"
variant="outline"
className="w-full"
disabled={loading}
onClick={() => setMode("self")}
>
Instance auto-hébergée
</Button>
</>
) : (
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault()
if (url.trim()) void select(url.trim())
}}
>
<Input
type="url"
inputMode="url"
autoCapitalize="none"
autoCorrect="off"
placeholder="https://mail.exemple.org"
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={loading}
/>
<Button
size="lg"
type="submit"
className="w-full"
disabled={loading || !url.trim()}
>
{loading ? "Connexion…" : "Continuer"}
</Button>
<Button
type="button"
variant="ghost"
className="w-full"
disabled={loading}
onClick={() => setMode("choose")}
>
Retour
</Button>
</form>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,19 @@
"use client"
import { ThemeProvider } from "@/components/theme-provider"
import { ClientThemeApplier } from "@/components/client-theme-applier"
/** Thème clair/sombre client (localStorage), partagé sur toute la web app. */
export function ClientThemeShell({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
storageKey="ultisuite-next-themes-cache"
>
{children}
<ClientThemeApplier />
</ThemeProvider>
)
}

View File

@ -10,6 +10,8 @@ import {
type FavoriteApp,
} from "@/lib/suite/favorite-apps"
import { MAIL_SETTINGS_BASE_PATH } from "@/lib/mail-settings/settings-nav"
import { SUITE_APP, suiteAppForRoute, useNativeRuntime } from "@/lib/platform"
import { openSiblingApp } from "@/lib/native/inter-app"
import { SUITE_HEADER_DROPDOWN_CLASS, SUITE_ICON_BTN } from "@/lib/suite/suite-chrome-classes"
import { cn } from "@/lib/utils"
@ -97,6 +99,24 @@ function FavoriteAppTile({
)
}
// In the native shells, opening another suite product launches the sibling
// app via its deep-link scheme rather than navigating inside this webview.
const targetApp = suiteAppForRoute(app.href)
if (useNativeRuntime() && targetApp && targetApp !== SUITE_APP) {
return (
<button
type="button"
className={FAVORITE_TILE_CLASS}
onClick={() => {
onNavigate?.()
void openSiblingApp(targetApp, app.href!)
}}
>
{content}
</button>
)
}
return (
<Link href={app.href} className={FAVORITE_TILE_CLASS} onClick={onNavigate}>
{content}

View File

@ -1,14 +1,6 @@
"use client"
import { ThemeProvider } from "@/components/theme-provider"
import { MailThemeApplier } from "@/components/gmail/mail-theme-applier"
/** Thème clair/sombre ; fond décoratif réservé aux routes /mail. */
/** @deprecated Préférer ClientThemeShell au layout racine ; conservé pour compat imports. */
export function SuiteThemeShell({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
<MailThemeApplier />
</ThemeProvider>
)
return children
}

View File

@ -47,29 +47,37 @@ export const THEME_INIT_SCRIPT = `
document.documentElement.dataset.splashApp = splashApp || "";
document.documentElement.dataset.splashSeen = splashSeen ? "1" : "0";
var isDemo = path === "/demo" || path.indexOf("/demo/") === 0;
var isLanding = path === "/";
var mode = "system";
var bgId = null;
if (isDemo) {
var demoRaw = localStorage.getItem("ultimail-demo-theme");
mode = "system";
if (demoRaw) {
function readPersistedThemeMode(raw) {
if (!raw) return null;
try {
var parsed = JSON.parse(raw);
var state = parsed.state || parsed;
return state.themeMode || null;
} catch (e) {
return null;
}
}
var clientRaw = localStorage.getItem("ultisuite-client-theme");
mode = readPersistedThemeMode(clientRaw) || "system";
if (!readPersistedThemeMode(clientRaw)) {
var legacyMail = readPersistedThemeMode(localStorage.getItem("ultimail-mail-settings"));
var legacyDemo = readPersistedThemeMode(localStorage.getItem("ultimail-demo-theme"));
mode = legacyMail || legacyDemo || "system";
}
if (isMail) {
var mailSettingsRaw = localStorage.getItem("ultimail-mail-settings");
if (mailSettingsRaw) {
try {
var demoParsed = JSON.parse(demoRaw);
mode = (demoParsed.state || demoParsed).themeMode || "system";
var mailParsed = JSON.parse(mailSettingsRaw);
var mailState = mailParsed.state || mailParsed;
bgId = mailState.backgroundId;
} catch (e) {}
}
} else if (isLanding) {
mode = "system";
} else {
var raw = localStorage.getItem("ultimail-mail-settings");
if (!raw) return;
var parsed = JSON.parse(raw);
var state = parsed.state || parsed;
mode = state.themeMode || "system";
bgId = state.backgroundId;
}
var resolved =
@ -120,6 +128,16 @@ export const THEME_INIT_SCRIPT = `
})();
`.trim()
/** Run theme / route bootstrap (mobile client mount — no blocking script in HTML). */
export function runThemeInit() {
try {
// eslint-disable-next-line no-new-func
new Function(THEME_INIT_SCRIPT)()
} catch {
/* best effort */
}
}
/**
* Script bloquant dans <head>. SSR rend script exécutable ; côté client type
* inerte pour éviter l'avertissement React 19 (le script a déjà tourné).

View File

@ -41,6 +41,7 @@ function Button({
variant,
size,
asChild = false,
type,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
@ -51,6 +52,7 @@ function Button({
return (
<Comp
data-slot="button"
type={asChild ? undefined : (type ?? 'button')}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>

View File

@ -2,7 +2,6 @@
import { useMemo } from "react"
import { useCurrentUser } from "@/lib/api/hooks/use-current-user"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import {
DEFAULT_AGENDA_ORG_SETTINGS,
DEFAULT_AGENDA_USER_SETTINGS,
@ -17,10 +16,13 @@ import {
normalizeAutoImportInvitationSources,
} from "@/lib/agenda/agenda-destination-identities"
import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
import {
DEMO_AGENDA_VISIBLE_HOURS_END,
DEMO_AGENDA_VISIBLE_HOURS_START,
} from "@/lib/demo/demo-agenda-settings"
import { useIsDemoAgenda } from "@/lib/demo/demo-agenda-context"
export type EffectiveAgendaSettings = AgendaUserSettings & {
themeMode: ReturnType<typeof useMailSettingsStore.getState>["themeMode"]
orgEnforcesTheme: boolean
orgEnforcesVideoProvider: boolean
}
@ -61,6 +63,7 @@ function clampMinutes(value: number, min: number, max: number): number {
}
export function useEffectiveAgendaSettings(): EffectiveAgendaSettings {
const isDemoAgenda = useIsDemoAgenda()
const defaultVideoProvider = useAgendaSettingsStore((s) => s.defaultVideoProvider)
const videoProviderApiKeys = useAgendaSettingsStore((s) => s.videoProviderApiKeys)
const defaultInvitationIdentityKey = useAgendaSettingsStore(
@ -81,13 +84,10 @@ export function useEffectiveAgendaSettings(): EffectiveAgendaSettings {
const timeFormat = useAgendaSettingsStore((s) => s.timeFormat)
const dragSnapMinutes = useAgendaSettingsStore((s) => s.dragSnapMinutes)
const buttonSnapMinutes = useAgendaSettingsStore((s) => s.buttonSnapMinutes)
const themeMode = useMailSettingsStore((s) => s.themeMode)
const { data: user } = useCurrentUser()
const org = user?.org_agenda ?? DEFAULT_ORG_AGENDA_PUBLIC
const orgEnforcesTheme = org.enforce_org_theme
const orgEnforcesVideoProvider = org.enforce_org_video_provider
const orgDefaultTheme = org.default_theme_mode
const orgDefaultVideoProvider = org.default_video_provider
return useMemo(() => {
@ -99,8 +99,12 @@ export function useEffectiveAgendaSettings(): EffectiveAgendaSettings {
invitationImportExclusions,
weekStart,
defaultQuickDurationMinutes,
visibleHoursStart,
visibleHoursEnd,
visibleHoursStart: isDemoAgenda
? DEMO_AGENDA_VISIBLE_HOURS_START
: visibleHoursStart,
visibleHoursEnd: isDemoAgenda
? DEMO_AGENDA_VISIBLE_HOURS_END
: visibleHoursEnd,
timeFormat,
dragSnapMinutes,
buttonSnapMinutes,
@ -112,8 +116,6 @@ export function useEffectiveAgendaSettings(): EffectiveAgendaSettings {
return {
...normalized,
defaultVideoProvider: provider,
themeMode: orgEnforcesTheme ? orgDefaultTheme : themeMode,
orgEnforcesTheme,
orgEnforcesVideoProvider,
}
}, [
@ -126,13 +128,11 @@ export function useEffectiveAgendaSettings(): EffectiveAgendaSettings {
defaultQuickDurationMinutes,
visibleHoursStart,
visibleHoursEnd,
isDemoAgenda,
timeFormat,
dragSnapMinutes,
buttonSnapMinutes,
themeMode,
orgEnforcesTheme,
orgEnforcesVideoProvider,
orgDefaultTheme,
orgDefaultVideoProvider,
])
}

View File

@ -1,8 +1,10 @@
import { getAiOrigin } from "@/lib/runtime-config"
/** Public path for OpenWebUI (default /ai). */
export function resolveAiEmbedBase(publicPath = "/ai"): string {
const path = (publicPath || "/ai").replace(/\/$/, "") || "/ai"
const normalized = path.startsWith("/") ? path : `/${path}`
const origin = process.env.NEXT_PUBLIC_AI_ORIGIN?.trim().replace(/\/$/, "")
const origin = getAiOrigin()?.trim().replace(/\/$/, "")
return origin ? `${origin}${normalized}` : normalized
}

View File

@ -2,6 +2,7 @@ import type { ApiError } from "./types"
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized"
import { isSessionExpired } from "@/lib/auth/session-guard-store"
import { getApiBaseUrl } from "@/lib/runtime-config"
export class OfflineError extends Error {
constructor() {
@ -29,12 +30,18 @@ const DEFAULT_RETRIES = 3
const BASE_DELAY = 1000
class ApiClient {
constructor(private baseUrl: string) {}
/**
* Resolver so the base URL is read at call time. On native the backend is
* only known after the server picker runs (runtime config); on web it stays
* the proxied `/api/v1`.
*/
constructor(private resolveBaseUrl: () => string = getApiBaseUrl) {}
private resolveUrl(path: string): URL {
const base = this.baseUrl.startsWith("http")
? this.baseUrl
: `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${this.baseUrl}`
const baseUrl = this.resolveBaseUrl()
const base = baseUrl.startsWith("http")
? baseUrl
: `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${baseUrl}`
// Absolute path (leading /) would replace /api/v1 — keep base path segment.
const normalizedBase = base.endsWith("/") ? base : `${base}/`
const normalizedPath = path.startsWith("/") ? path.slice(1) : path
@ -231,4 +238,4 @@ class ApiClient {
}
}
export const apiClient = new ApiClient(process.env.NEXT_PUBLIC_API_URL ?? "/api/v1")
export const apiClient = new ApiClient()

View File

@ -0,0 +1,57 @@
import { apiClient } from "@/lib/api/client"
import type { ContactImportInput } from "@/lib/contacts/import-parsers"
/** Backend bulk-import item (user derived from token; never in the body). */
export type BulkImportContact = {
full_name?: string
email?: string
phone?: string
org?: string
uid?: string
raw_vcard?: string
}
export type BulkImportResult = {
created: number
failed: { index: number; error: string }[]
}
/** Backend caps: 5000 contacts / request, 8 MiB body. */
const MAX_PER_REQUEST = 5000
export function importInputToBulk(c: ContactImportInput): BulkImportContact {
const fullName = `${c.firstName} ${c.lastName}`.trim()
return {
full_name: fullName || c.emails[0]?.value || undefined,
email: c.emails[0]?.value,
phone: c.phones[0]?.value,
org: c.company,
}
}
/**
* Bulk import contacts into a book via POST /contacts/books/{bookID}/import,
* chunked to respect the backend per-request cap. Replaces the previous
* N-times-POST loop.
*/
export async function bulkImportContacts(
bookId: string,
contacts: (BulkImportContact | string)[]
): Promise<BulkImportResult> {
let created = 0
const failed: { index: number; error: string }[] = []
for (let offset = 0; offset < contacts.length; offset += MAX_PER_REQUEST) {
const chunk = contacts.slice(offset, offset + MAX_PER_REQUEST)
const res = await apiClient.post<BulkImportResult>(
`/contacts/books/${encodeURIComponent(bookId)}/import`,
{ contacts: chunk }
)
created += res?.created ?? 0
for (const f of res?.failed ?? []) {
failed.push({ index: f.index + offset, error: f.error })
}
}
return { created, failed }
}

View File

@ -11,6 +11,7 @@ import {
withPathRefBody,
} from "@/lib/api/drive-roots"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { getApiBaseUrl } from "@/lib/runtime-config"
import { useDemoDrive, useIsDemoDrive } from "@/lib/demo/demo-drive-context"
import { DEMO_DRIVE_QUERY_ROOT } from "@/lib/demo/demo-drive-bootstrap"
import { useDemoDriveStore } from "@/lib/demo/demo-drive-store"
@ -544,7 +545,7 @@ export function useDriveMountMutations() {
/** @deprecated Use openDriveFileInNewTab / downloadDriveFile — API requires Authorization. */
export function fileDownloadUrl(path: string): string {
const base = process.env.NEXT_PUBLIC_API_URL ?? "/api/v1"
const base = getApiBaseUrl()
return `${base}/drive/download${path.startsWith("/") ? path : `/${path}`}`
}

View File

@ -1,6 +1,5 @@
import type { useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "/api/v1"
import { getApiBaseUrl } from "@/lib/runtime-config"
function withPassword(url: string, password?: string) {
if (!password) return url
@ -14,7 +13,7 @@ function fileApiPath(token: string, filePath: string) {
.split("/")
.filter(Boolean)
.map(encodeURIComponent)
return `${API_BASE}/drive/public/shares/${encodeURIComponent(token)}/files/${parts.join("/")}`
return `${getApiBaseUrl()}/drive/public/shares/${encodeURIComponent(token)}/files/${parts.join("/")}`
}
function folderApiPath(token: string, folderPath: string) {
@ -23,7 +22,7 @@ function folderApiPath(token: string, folderPath: string) {
.split("/")
.filter(Boolean)
.map(encodeURIComponent)
return `${API_BASE}/drive/public/shares/${encodeURIComponent(token)}/folders/${parts.join("/")}`
return `${getApiBaseUrl()}/drive/public/shares/${encodeURIComponent(token)}/folders/${parts.join("/")}`
}
export async function uploadPublicShareFile(
@ -71,7 +70,7 @@ export async function renamePublicShareItem(
newName: string,
password?: string
) {
const res = await fetch(withPassword(`${API_BASE}/drive/public/shares/${encodeURIComponent(token)}/rename`, password), {
const res = await fetch(withPassword(`${getApiBaseUrl()}/drive/public/shares/${encodeURIComponent(token)}/rename`, password), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: filePath, new_name: newName }),

View File

@ -1,11 +1,10 @@
import { useAuthStore } from "@/lib/api/auth-store"
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "/api/v1"
import { getApiBaseUrl } from "@/lib/runtime-config"
export async function uploadFile(targetPath: string, file: File, onProgress?: (pct: number) => void) {
const token = useAuthStore.getState().accessToken
const path = targetPath.startsWith("/") ? targetPath : `/${targetPath}`
const url = `${API_BASE}/drive/files${path}`
const url = `${getApiBaseUrl()}/drive/files${path}`
const res = await fetch(url, {
method: "POST",

View File

@ -6,6 +6,7 @@ import type { WsEvent, WsEventType, WsMailPayload } from "./types"
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
import { useAuthStore } from "./auth-store"
import { useIsDemoApp } from "@/lib/demo/use-is-demo-app"
import { getWsUrl } from "@/lib/runtime-config"
export type WsEventListener = (evt: WsEvent) => void
@ -40,11 +41,8 @@ class WebSocketManager {
connect(token: string) {
if (this.ws?.readyState === WebSocket.OPEN) return
const baseUrl =
process.env.NEXT_PUBLIC_WS_URL ??
(typeof window !== "undefined"
? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`
: "")
const baseUrl = getWsUrl() ?? ""
if (!baseUrl) return
const url = `${baseUrl}?token=${encodeURIComponent(token)}&since=${this.lastSeq}`
this.ws = new WebSocket(url)

View File

@ -1,12 +1,21 @@
import { useAuthStore } from "@/lib/api/auth-store"
import { fetchSession, applySessionToStore } from "@/lib/auth/session-sync"
import { useNativeRuntime } from "@/lib/platform"
import { ensureNativeAccessToken } from "@/lib/auth/native-session"
let syncPromise: Promise<string | null> | null = null
/** Bearer token comes from httpOnly session cookies — never trust localStorage cache. */
/**
* Resolve the current bearer token.
* - Web: from the httpOnly session cookies (via `/api/auth/session`).
* - Native (Tauri): from the OS secure store, refreshing against Authentik.
*/
export async function ensureAccessToken(): Promise<string | null> {
if (!syncPromise) {
syncPromise = (async () => {
if (useNativeRuntime()) {
return ensureNativeAccessToken()
}
const data = await fetchSession()
if (data && applySessionToStore(data)) {
return useAuthStore.getState().accessToken

245
lib/auth/native-auth.ts Normal file
View File

@ -0,0 +1,245 @@
/**
* Native OIDC Authorization Code + PKCE flow for the Tauri shells.
*
* Replaces the server-side `/api/auth/*` routes (httpOnly cookies) on mobile:
* - opens the system browser (ASWebAuthenticationSession on iOS / Custom Tabs
* on Android) to the Authentik authorize endpoint,
* - receives the `ulti<app>://oauth/callback` redirect via the deep-link plugin
* (forwarded as the `ulti://deep-link` window event by the Rust shell),
* - exchanges the code for tokens against a **public** PKCE client (no secret),
* - persists the session in the shared OS secure store (cross-app SSO).
*/
import { createPkcePair, randomString } from "@/lib/auth/pkce"
import { platformUserFromToken } from "@/lib/auth/jwt-claims"
import { getRuntimeConfig } from "@/lib/runtime-config"
import { isTauriRuntime } from "@/lib/platform"
import { listen } from "@/lib/native/bridge"
import {
clearSession,
readSession,
writeSession,
type NativeSession,
} from "@/lib/native/secure-store"
const PENDING_KEY = "ulti-native-oauth-pending"
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000
type PendingAuth = {
verifier: string
state: string
returnTo: string
}
type TokenResponse = {
access_token?: string
refresh_token?: string
expires_in?: number
id_token?: string
}
function savePending(p: PendingAuth) {
sessionStorage.setItem(PENDING_KEY, JSON.stringify(p))
}
function takePending(): PendingAuth | null {
const raw = sessionStorage.getItem(PENDING_KEY)
if (!raw) return null
sessionStorage.removeItem(PENDING_KEY)
try {
return JSON.parse(raw) as PendingAuth
} catch {
return null
}
}
async function openExternal(url: string) {
if (isTauriRuntime()) {
try {
const opener = await import("@tauri-apps/plugin-opener")
await opener.openUrl(url)
return
} catch {
/* fall through to window.open */
}
}
window.open(url, "_blank", "noopener")
}
function buildSession(tokens: TokenResponse): NativeSession {
const bearer = tokens.id_token ?? tokens.access_token
if (!bearer) throw new Error("no_id_token")
const expiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000
return {
accessToken: bearer,
refreshToken: tokens.refresh_token ?? null,
expiresAt,
user: platformUserFromToken(bearer),
}
}
async function exchangeCode(code: string, verifier: string): Promise<NativeSession> {
const cfg = getRuntimeConfig()
if (!cfg) throw new Error("no_runtime_config")
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: cfg.oidc.clientId,
code,
redirect_uri: cfg.oidc.redirectUri,
code_verifier: verifier,
})
const res = await fetch(cfg.oidc.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
})
if (!res.ok) {
throw new Error(`token_exchange_failed:${res.status}`)
}
const session = buildSession((await res.json()) as TokenResponse)
await writeSession(session)
return session
}
/** Parse `code`/`state` out of a returned `ulti<app>://oauth/callback?...` URL. */
function parseCallback(url: string): { code?: string; state?: string; error?: string } {
try {
const u = new URL(url)
return {
code: u.searchParams.get("code") ?? undefined,
state: u.searchParams.get("state") ?? undefined,
error: u.searchParams.get("error") ?? undefined,
}
} catch {
return {}
}
}
/**
* Run the full native login. Resolves with the new session once the deep-link
* callback is received and the code is exchanged.
*/
export async function nativeStartLogin(options?: {
returnTo?: string
prompt?: string
}): Promise<NativeSession> {
const cfg = getRuntimeConfig()
if (!cfg) throw new Error("no_runtime_config")
const { verifier, challenge } = await createPkcePair()
const state = randomString(16)
const returnTo = options?.returnTo ?? "/mail/inbox"
savePending({ verifier, state, returnTo })
const params = new URLSearchParams({
client_id: cfg.oidc.clientId,
redirect_uri: cfg.oidc.redirectUri,
response_type: "code",
scope: "openid profile email offline_access",
state,
code_challenge: challenge,
code_challenge_method: "S256",
prompt: options?.prompt ?? "select_account",
})
const authorizeUrl = `${cfg.oidc.authorizationEndpoint}?${params.toString()}`
return new Promise<NativeSession>((resolve, reject) => {
let settled = false
let unlisten: (() => void) | null = null
const cleanup = () => {
settled = true
if (unlisten) unlisten()
clearTimeout(timer)
}
const timer = setTimeout(() => {
if (settled) return
cleanup()
reject(new Error("login_timeout"))
}, LOGIN_TIMEOUT_MS)
const handleUrl = async (rawUrls: unknown) => {
if (settled) return
const urls = Array.isArray(rawUrls) ? rawUrls : [rawUrls]
for (const raw of urls) {
if (typeof raw !== "string") continue
if (!raw.includes("oauth/callback")) continue
const { code, state: returnedState, error } = parseCallback(raw)
const pending = takePending()
if (error) {
cleanup()
reject(new Error(error))
return
}
if (!code || !pending || returnedState !== pending.state) {
cleanup()
reject(new Error("invalid_state"))
return
}
try {
const session = await exchangeCode(code, pending.verifier)
cleanup()
resolve(session)
} catch (err) {
cleanup()
reject(err instanceof Error ? err : new Error("exchange_failed"))
}
return
}
}
void (async () => {
unlisten = await listen("ulti://deep-link", (payload) => {
void handleUrl(payload)
})
await openExternal(authorizeUrl)
})().catch((err) => {
if (settled) return
cleanup()
reject(err instanceof Error ? err : new Error("open_failed"))
})
})
}
/** Refresh the session using the stored refresh token (public client). */
export async function nativeRefresh(): Promise<NativeSession | null> {
const cfg = getRuntimeConfig()
const session = await readSession()
if (!cfg || !session?.refreshToken) return null
const body = new URLSearchParams({
grant_type: "refresh_token",
client_id: cfg.oidc.clientId,
refresh_token: session.refreshToken,
})
try {
const res = await fetch(cfg.oidc.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
})
if (!res.ok) return null
const tokens = (await res.json()) as TokenResponse
const next = buildSession({
...tokens,
refresh_token: tokens.refresh_token ?? session.refreshToken,
})
await writeSession(next)
return next
} catch {
return null
}
}
/** Clear the native session (and end the Authentik session in the browser). */
export async function nativeLogout(): Promise<void> {
const cfg = getRuntimeConfig()
await clearSession()
if (cfg?.oidc.endSessionEndpoint) {
try {
await openExternal(cfg.oidc.endSessionEndpoint)
} catch {
/* best effort */
}
}
}

View File

@ -0,0 +1,56 @@
/**
* Native session bridge: reads the session from the OS secure store, applies it
* to the in-memory auth store, and refreshes against Authentik when needed.
* Mirrors `session-sync.ts` (the web/httpOnly-cookie path) for the Tauri shells.
*/
import { useAuthStore } from "@/lib/api/auth-store"
import { useSessionGuardStore } from "@/lib/auth/session-guard-store"
import type { PlatformUser } from "@/lib/auth/jwt-claims"
import { readSession, type NativeSession } from "@/lib/native/secure-store"
import { nativeRefresh } from "@/lib/auth/native-auth"
const REFRESH_LEAD_MS = 60_000
function applyNativeSession(session: NativeSession | null): boolean {
if (session?.accessToken && session.expiresAt) {
useAuthStore
.getState()
.login(
session.accessToken,
session.refreshToken ?? "",
session.expiresAt,
(session.user as PlatformUser | null) ?? null
)
useSessionGuardStore.getState().clear()
return true
}
return false
}
/** Hydrate the store from the secure store (used on boot). */
export async function loadNativeSession(): Promise<boolean> {
return applyNativeSession(await readSession())
}
/** Return a valid bearer token, refreshing first if it's about to expire. */
export async function ensureNativeAccessToken(): Promise<string | null> {
let session = await readSession()
if (!session) {
useAuthStore.getState().logout()
return null
}
const aboutToExpire = Date.now() >= session.expiresAt - REFRESH_LEAD_MS
if (aboutToExpire && session.refreshToken) {
const refreshed = await nativeRefresh()
if (refreshed) session = refreshed
}
if (Date.now() >= session.expiresAt && !session.refreshToken) {
useAuthStore.getState().logout()
return null
}
applyNativeSession(session)
return useAuthStore.getState().accessToken
}

View File

@ -1,4 +1,6 @@
/** OIDC settings for local dev (Authentik blueprints in ulti-backend). */
import { useNativeRuntime } from "@/lib/platform"
import { getRuntimeConfig } from "@/lib/runtime-config"
function trimSlash(url: string) {
return url.endsWith("/") ? url : `${url}/`
@ -30,8 +32,16 @@ export function getAuthentikBase(): string {
return issuer.replace(/application\/o\/[^/]+\/?$/, "")
}
/** Authentik enrollment flow (same origin as the suite — nginx /auth/). */
const AUTHENTIK_ENROLLMENT_PATH = "/auth/if/flow/ulti-enrollment/"
export function getAuthentikEnrollmentUrl(): string {
return `${getAuthentikBase()}if/flow/ulti-enrollment/`
if (useNativeRuntime()) {
const cfg = getRuntimeConfig()
if (cfg?.oidc.enrollmentUrl) return cfg.oidc.enrollmentUrl
}
// Relative URL: identical SSR/client on localhost, tunnel, prod (no NEXT_PUBLIC mismatch).
return AUTHENTIK_ENROLLMENT_PATH
}
type OidcDiscovery = {
@ -142,6 +152,21 @@ function toServerEndpoint(
}
export function getPublicOidcConfig(): OidcConfig {
if (useNativeRuntime()) {
const cfg = getRuntimeConfig()
if (cfg) {
return {
issuer: cfg.oidc.issuer,
clientId: cfg.oidc.clientId,
appUrl: cfg.instanceOrigin,
redirectUri: cfg.oidc.redirectUri,
authorizationEndpoint: cfg.oidc.authorizationEndpoint,
tokenEndpoint: cfg.oidc.tokenEndpoint,
endSessionEndpoint: cfg.oidc.endSessionEndpoint,
}
}
}
const issuer = trimSlash(
process.env.NEXT_PUBLIC_OIDC_ISSUER ??
"http://localhost/auth/application/o/ulti/"

View File

@ -2,8 +2,13 @@
import { useLayoutEffect } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { useDemoAgendaStore } from "@/lib/demo/demo-agenda-store"
import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
import { useSessionGuardStore } from "@/lib/auth/session-guard-store"
import {
DEMO_AGENDA_VISIBLE_HOURS_END,
DEMO_AGENDA_VISIBLE_HOURS_START,
} from "@/lib/demo/demo-agenda-settings"
import { useDemoAgendaStore } from "@/lib/demo/demo-agenda-store"
export const DEMO_AGENDA_QUERY_ROOT = ["demo", "agenda"] as const
@ -13,6 +18,11 @@ export function DemoAgendaBootstrap() {
useLayoutEffect(() => {
useSessionGuardStore.getState().clear()
useDemoAgendaStore.getState().reset()
const agendaSettings = useAgendaSettingsStore.getState()
agendaSettings.setVisibleHoursStart(DEMO_AGENDA_VISIBLE_HOURS_START)
agendaSettings.setVisibleHoursEnd(DEMO_AGENDA_VISIBLE_HOURS_END)
queryClient.removeQueries({ queryKey: DEMO_AGENDA_QUERY_ROOT })
}, [queryClient])

View File

@ -0,0 +1,3 @@
/** Heures visibles par défaut dans la démo UltiCal (8:00 → 20:00). */
export const DEMO_AGENDA_VISIBLE_HOURS_START = 8 * 60
export const DEMO_AGENDA_VISIBLE_HOURS_END = 20 * 60

View File

@ -1,5 +1,6 @@
import {
DEMO_EMAILS,
DEMO_REFERENCE_DATE_UTC,
DEMO_USER,
type DemoEmail,
} from "@/components/demo/demo-mail-data"
@ -37,31 +38,41 @@ function escapeHtml(text: string): string {
.replace(/"/g, "&quot;")
}
function demoReferenceMidnightUtc(): Date {
return new Date(
Date.UTC(
DEMO_REFERENCE_DATE_UTC.year,
DEMO_REFERENCE_DATE_UTC.month,
DEMO_REFERENCE_DATE_UTC.day
)
)
}
/** Map demo list labels to stable ISO timestamps (SSR-safe, no `new Date()`). */
function demoTimeToIso(time: string): string {
const now = new Date()
const base = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const base = demoReferenceMidnightUtc()
if (time.includes(":")) {
const [h, m] = time.split(":").map(Number)
base.setHours(h, m ?? 0, 0, 0)
base.setUTCHours(h, m ?? 0, 0, 0)
return base.toISOString()
}
if (time === "Hier") {
base.setDate(base.getDate() - 1)
base.setHours(14, 20, 0, 0)
base.setUTCDate(base.getUTCDate() - 1)
base.setUTCHours(14, 20, 0, 0)
return base.toISOString()
}
if (time === "Dim.") {
const day = base.getDay()
const day = base.getUTCDay()
const diff = day === 0 ? 0 : day
base.setDate(base.getDate() - diff)
base.setHours(3, 0, 0, 0)
base.setUTCDate(base.getUTCDate() - diff)
base.setUTCHours(3, 0, 0, 0)
return base.toISOString()
}
if (time.startsWith("Lun")) {
const day = base.getDay()
const day = base.getUTCDay()
const diff = day === 0 ? 6 : day - 1
base.setDate(base.getDate() - diff)
base.setHours(11, 0, 0, 0)
base.setUTCDate(base.getUTCDate() - diff)
base.setUTCHours(11, 0, 0, 0)
return base.toISOString()
}
const weekdayOffsets: Record<string, number> = {
@ -73,14 +84,14 @@ function demoTimeToIso(time: string): string {
}
for (const [prefix, weekday] of Object.entries(weekdayOffsets)) {
if (time.startsWith(prefix)) {
const day = base.getDay()
const day = base.getUTCDay()
const diff = day >= weekday ? day - weekday : day + (7 - weekday)
base.setDate(base.getDate() - diff)
base.setHours(10, 30, 0, 0)
base.setUTCDate(base.getUTCDate() - diff)
base.setUTCHours(10, 30, 0, 0)
return base.toISOString()
}
}
base.setHours(12, 0, 0, 0)
base.setUTCHours(12, 0, 0, 0)
return base.toISOString()
}

View File

@ -1,24 +1,14 @@
"use client"
import type { MailThemeMode } from "@/lib/mail-settings/types"
import { useDemoThemeStore } from "@/lib/demo/demo-theme-store"
import { useIsDemoApp } from "@/lib/demo/use-is-demo-app"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { useClientThemeStore } from "@/lib/stores/client-theme-store"
/** Thème clair/sombre/système : store démo isolé sur /demo/*, sinon réglages mail. */
/** Thème clair/sombre/système — préférence client (localStorage), globale à la web app. */
export function useThemeModeControls(): {
themeMode: MailThemeMode
setThemeMode: (mode: MailThemeMode) => void
} {
const isDemo = useIsDemoApp()
const mailThemeMode = useMailSettingsStore((s) => s.themeMode)
const setMailThemeMode = useMailSettingsStore((s) => s.setThemeMode)
const demoThemeMode = useDemoThemeStore((s) => s.themeMode)
const setDemoThemeMode = useDemoThemeStore((s) => s.setThemeMode)
if (isDemo) {
return { themeMode: demoThemeMode, setThemeMode: setDemoThemeMode }
}
return { themeMode: mailThemeMode, setThemeMode: setMailThemeMode }
const themeMode = useClientThemeStore((s) => s.themeMode)
const setThemeMode = useClientThemeStore((s) => s.setThemeMode)
return { themeMode, setThemeMode }
}

View File

@ -10,9 +10,12 @@ export const DRIVE_CARD_PAD_X = "px-4"
/** Top padding for content inside the scroll area (not on the scroll container — avoids a gap above sticky headers). */
export const DRIVE_CARD_SCROLL_PT = "pt-3"
/** Space between filter/bulk chrome and file grid or list. */
/** Space between filter/bulk chrome and file grid. */
export const DRIVE_FILTER_CONTENT_GAP = "pb-4"
/** Tighter gap before list column headers (headers carry their own top padding). */
export const DRIVE_FILTER_LIST_CONTENT_GAP = "pb-0"
/** Spacer below scrollable file content — xs clears bottom bar, sm+ adds card breathing room. */
export const DRIVE_SCROLL_END_SPACER_CLASS =
"shrink-0 h-6 max-sm:h-[calc(5.5rem+env(safe-area-inset-bottom))]"

View File

@ -3,7 +3,6 @@ import type {
InboxSortMode,
MailBackgroundId,
MailDensity,
MailThemeMode,
ReadingPaneMode,
} from '@/lib/mail-settings/types'
@ -25,7 +24,6 @@ export function apiSettingsToStore(settings: ApiMailSettings) {
const n = settings.notifications
return {
density: settings.density as MailDensity,
themeMode: settings.theme_mode as MailThemeMode,
backgroundId: settings.background_id as MailBackgroundId,
inboxSort: settings.inbox_sort as InboxSortMode,
readingPane: settings.reading_pane as ReadingPaneMode,
@ -39,7 +37,6 @@ export function apiSettingsToStore(settings: ApiMailSettings) {
export function storeSettingsToPatch(settings: {
density?: MailDensity
themeMode?: MailThemeMode
backgroundId?: MailBackgroundId
inboxSort?: InboxSortMode
readingPane?: ReadingPaneMode
@ -51,7 +48,6 @@ export function storeSettingsToPatch(settings: {
}): Partial<ApiMailSettings> {
const patch: Partial<ApiMailSettings> = {}
if (settings.density !== undefined) patch.density = settings.density
if (settings.themeMode !== undefined) patch.theme_mode = settings.themeMode
if (settings.backgroundId !== undefined) patch.background_id = settings.backgroundId
if (settings.inboxSort !== undefined) patch.inbox_sort = settings.inboxSort
if (settings.readingPane !== undefined) patch.reading_pane = settings.readingPane

68
lib/native/bridge.ts Normal file
View File

@ -0,0 +1,68 @@
/**
* Thin bridge over the Tauri JS API. Everything is dynamically imported so the
* web bundle never pulls in `@tauri-apps/*` and `pnpm build` (web) keeps
* working even if the native packages are absent.
*/
import { isTauriRuntime } from "@/lib/platform"
type InvokeFn = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>
type ListenFn = (
event: string,
handler: (e: { payload: unknown }) => void
) => Promise<() => void>
let invokeImpl: InvokeFn | null = null
let listenImpl: ListenFn | null = null
async function loadCore(): Promise<InvokeFn | null> {
if (!isTauriRuntime()) return null
if (invokeImpl) return invokeImpl
try {
const mod = (await import("@tauri-apps/api/core")) as { invoke: InvokeFn }
invokeImpl = mod.invoke
return invokeImpl
} catch {
return null
}
}
async function loadEvent(): Promise<ListenFn | null> {
if (!isTauriRuntime()) return null
if (listenImpl) return listenImpl
try {
const mod = (await import("@tauri-apps/api/event")) as { listen: ListenFn }
listenImpl = mod.listen
return listenImpl
} catch {
return null
}
}
/** Invoke a Tauri command. Returns null (no-op) outside a Tauri webview or on timeout. */
export async function invoke<T>(
cmd: string,
args?: Record<string, unknown>
): Promise<T | null> {
const fn = await loadCore()
if (!fn) return null
try {
return await Promise.race([
fn<T>(cmd, args),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("invoke_timeout")), 5_000)
}),
])
} catch {
return null
}
}
/** Subscribe to a Tauri event. Returns an unsubscribe fn (no-op on web). */
export async function listen(
event: string,
handler: (payload: unknown) => void
): Promise<() => void> {
const fn = await loadEvent()
if (!fn) return () => {}
return fn(event, (e) => handler(e.payload))
}

52
lib/native/contacts.ts Normal file
View File

@ -0,0 +1,52 @@
/**
* Device contacts import (mobile). Reads the OS address book via the
* `ulti-core` contacts plugin (Android ContactsContract / iOS Contacts) and
* maps to the shared `ContactImportInput` shape used by the import dialog.
*/
import { invoke } from "@/lib/native/bridge"
import { isTauriRuntime } from "@/lib/platform"
import type { ContactImportInput } from "@/lib/contacts/import-parsers"
type DeviceContact = {
display_name?: string | null
first_name?: string | null
last_name?: string | null
emails: string[]
phones: string[]
organization?: string | null
}
function splitDisplayName(name: string): { firstName: string; lastName: string } {
const parts = name.trim().split(/\s+/)
if (parts.length <= 1) return { firstName: parts[0] ?? "", lastName: "" }
return { firstName: parts[0], lastName: parts.slice(1).join(" ") }
}
function toImportInput(c: DeviceContact): ContactImportInput {
let firstName = c.first_name ?? ""
let lastName = c.last_name ?? ""
if (!firstName && !lastName && c.display_name) {
const parts = splitDisplayName(c.display_name)
firstName = parts.firstName
lastName = parts.lastName
}
return {
firstName,
lastName,
company: c.organization ?? undefined,
emails: c.emails.map((value) => ({ value, label: "personal" })),
phones: c.phones.map((value) => ({ value, label: "mobile" })),
}
}
/** True when device contacts import is available (native runtime). */
export function deviceContactsAvailable(): boolean {
return isTauriRuntime()
}
/** Fetch device contacts. Throws a coded error if unsupported/denied. */
export async function fetchDeviceContacts(): Promise<ContactImportInput[]> {
const list = await invoke<DeviceContact[]>("plugin:ulti-core|contacts_fetch")
if (!list) throw new Error("contacts_unavailable")
return list.map(toImportInput)
}

53
lib/native/deep-links.ts Normal file
View File

@ -0,0 +1,53 @@
/**
* Map an inbound deep link (custom scheme or universal/app link) to an in-app
* route for the Next router.
*
* Conventions:
* - Custom scheme: `ulti<app>://go/<route>` -> `/<route>`
* `ulti<app>://<seg>/<rest>` -> `/<seg>/<rest>`
* - Universal link: `https://<host>/app/<app>/<rest>` -> `/<rest>`
*
* The OAuth callback (`ulti<app>://oauth/callback`) is consumed by the native
* auth flow and intentionally returns null here.
*/
export function routeForDeepLink(rawUrl: string): string | null {
let u: URL
try {
u = new URL(rawUrl)
} catch {
return null
}
// Custom scheme (e.g. ultimail:, ultidrive:)
if (u.protocol.startsWith("ulti") && u.protocol.endsWith(":")) {
if (u.hostname === "oauth") return null // handled by native-auth
const host = u.hostname
const path = u.pathname.replace(/^\/+/, "")
let route: string
if (host === "go" || host === "") {
route = `/${path}`
} else {
route = `/${host}${path ? `/${path}` : ""}`
}
return normalize(route, u.search)
}
// Universal / App link: https://host/app/<app>/<rest>
if (u.protocol === "https:" || u.protocol === "http:") {
const parts = u.pathname.split("/").filter(Boolean)
const appIdx = parts.indexOf("app")
if (appIdx >= 0 && parts.length > appIdx + 1) {
const rest = parts.slice(appIdx + 2).join("/")
return normalize(`/${rest}`, u.search)
}
return normalize(u.pathname, u.search)
}
return null
}
function normalize(route: string, search: string): string {
const cleaned = route.replace(/\/{2,}/g, "/")
const path = cleaned === "/" || cleaned === "" ? "/" : cleaned.replace(/\/$/, "")
return `${path}${search ?? ""}`
}

31
lib/native/inter-app.ts Normal file
View File

@ -0,0 +1,31 @@
/**
* Inter-app launching: open a sibling suite app at a given route via its custom
* scheme (e.g. UltiMail opening `ultidrive://go/drive/...`). Falls back to the
* web route in a plain browser.
*/
import { invoke } from "@/lib/native/bridge"
import { appScheme, isTauriRuntime, type SuiteApp } from "@/lib/platform"
/** Build the deep link that opens `app` at `route`. */
export function siblingDeepLink(app: SuiteApp, route: string): string {
const clean = route.startsWith("/") ? route.slice(1) : route
return `${appScheme(app)}://go/${clean}`
}
/** Open a sibling app at a route (deep link). On web, navigate in-page. */
export async function openSiblingApp(app: SuiteApp, route: string): Promise<void> {
const link = siblingDeepLink(app, route)
if (isTauriRuntime()) {
try {
const opener = await import("@tauri-apps/plugin-opener")
await opener.openUrl(link)
return
} catch {
await invoke("plugin:ulti-core|app_open_url", { url: link })
return
}
}
if (typeof window !== "undefined") {
window.location.href = route
}
}

81
lib/native/push.ts Normal file
View File

@ -0,0 +1,81 @@
/**
* App-side push notifications.
*
* The native push plugin (FCM on Android / APNs on iOS) obtains the device
* token and either caches it in the secure store under "push_token" or emits a
* `ulti://push-token` event. After login we register it with the backend; on
* logout we unregister it.
*
* Backend contract:
* POST /api/v1/devices/register { platform, app, push_token, device_id? } -> { id }
* POST /api/v1/devices/unregister { push_token }
*/
import { apiClient } from "@/lib/api/client"
import { invoke, listen } from "@/lib/native/bridge"
import { isTauriRuntime, SUITE_APP } from "@/lib/platform"
import { readPushToken, writePushToken } from "@/lib/native/secure-store"
type PushRegistration = { platform: string; token: string | null }
function normalizePlatform(os: string): "ios" | "android" | null {
if (os === "ios") return "ios"
if (os === "android") return "android"
return null
}
let started = false
async function registerToken(platform: "ios" | "android", token: string) {
await writePushToken(token)
await apiClient.post<{ id: string }>("/devices/register", {
platform,
app: SUITE_APP,
push_token: token,
})
}
/**
* Call after a successful login. Idempotent. Asks the native layer for the push
* token and registers it; also subscribes to late token deliveries.
*/
export async function registerPushAfterLogin(): Promise<void> {
if (!isTauriRuntime()) return
// Late token delivery (e.g. permission granted after first launch).
if (!started) {
started = true
await listen("ulti://push-token", (payload) => {
const token = typeof payload === "string" ? payload : null
const platform = normalizePlatform(
(navigator?.platform || "").toLowerCase().includes("android")
? "android"
: "ios"
)
if (token && platform) void registerToken(platform, token)
})
}
try {
const reg = await invoke<PushRegistration>("plugin:ulti-core|push_register")
if (!reg) return
const platform = normalizePlatform(reg.platform)
if (platform && reg.token) {
await registerToken(platform, reg.token)
}
} catch {
/* push optional — never block login */
}
}
/** Call before clearing the session. */
export async function unregisterPushOnLogout(): Promise<void> {
if (!isTauriRuntime()) return
try {
const token = await readPushToken()
if (token) {
await apiClient.post("/devices/unregister", { push_token: token })
}
} catch {
/* best effort */
}
}

View File

@ -0,0 +1,87 @@
/**
* JS facade over the `ulti-core` secure store Tauri commands. The store is
* shared across the suite (cross-app SSO) so the session written by one app is
* readable by the others.
*/
import { invoke } from "@/lib/native/bridge"
import { isTauriRuntime } from "@/lib/platform"
const KEY_SESSION = "session"
const KEY_CONFIG = "runtime_config"
const KEY_PUSH = "push_token"
/**
* Fallback for a mobile build opened in a plain browser (dev): persist to
* localStorage so the native flow can be exercised without a device.
*/
function localFallback() {
return typeof window !== "undefined" ? window.localStorage : null
}
async function set(key: string, value: string): Promise<void> {
if (isTauriRuntime()) {
await invoke("plugin:ulti-core|store_set", { key, value })
return
}
localFallback()?.setItem(`ulti-secure:${key}`, value)
}
async function get(key: string): Promise<string | null> {
if (isTauriRuntime()) {
return (await invoke<string | null>("plugin:ulti-core|store_get", { key })) ?? null
}
return localFallback()?.getItem(`ulti-secure:${key}`) ?? null
}
async function del(key: string): Promise<void> {
if (isTauriRuntime()) {
await invoke("plugin:ulti-core|store_delete", { key })
return
}
localFallback()?.removeItem(`ulti-secure:${key}`)
}
export type NativeSession = {
accessToken: string
refreshToken: string | null
expiresAt: number
user: unknown | null
}
export async function readSession(): Promise<NativeSession | null> {
const raw = await get(KEY_SESSION)
if (!raw) return null
try {
return JSON.parse(raw) as NativeSession
} catch {
return null
}
}
export async function writeSession(session: NativeSession): Promise<void> {
await set(KEY_SESSION, JSON.stringify(session))
}
export async function clearSession(): Promise<void> {
if (isTauriRuntime()) {
await invoke("plugin:ulti-core|store_clear")
return
}
await del(KEY_SESSION)
}
export async function readStoredConfig(): Promise<string | null> {
return get(KEY_CONFIG)
}
export async function writeStoredConfig(json: string): Promise<void> {
await set(KEY_CONFIG, json)
}
export async function readPushToken(): Promise<string | null> {
return get(KEY_PUSH)
}
export async function writePushToken(token: string): Promise<void> {
await set(KEY_PUSH, token)
}

80
lib/native/share.ts Normal file
View File

@ -0,0 +1,80 @@
/**
* Share send/receive for the native shells.
* - Receive: the OS hands shared content to the app (Android ACTION_SEND
* intent / iOS Share Extension), which the native layer queues; we drain it
* here and stash it for the compose/upload flow.
* - Send: push content to the OS share sheet via the `share_out` command.
*/
import { invoke } from "@/lib/native/bridge"
import { isTauriRuntime, SUITE_APP } from "@/lib/platform"
export type SharePayload = {
kind: string
text?: string | null
url?: string | null
files: string[]
mime?: string | null
}
const PENDING_SHARE_KEY = "ulti-pending-share"
/** Drain a pending inbound share payload (if any). */
export async function takePendingShare(): Promise<SharePayload | null> {
if (!isTauriRuntime()) return null
try {
return (await invoke<SharePayload | null>("plugin:ulti-core|share_take_pending")) ?? null
} catch {
return null
}
}
/** Stash a share payload for the in-app compose/upload flow to pick up. */
export function stashShare(payload: SharePayload) {
try {
sessionStorage.setItem(PENDING_SHARE_KEY, JSON.stringify(payload))
} catch {
/* ignore */
}
}
/** Read+clear a stashed share payload (consumed by compose/upload screens). */
export function consumeStashedShare(): SharePayload | null {
try {
const raw = sessionStorage.getItem(PENDING_SHARE_KEY)
if (!raw) return null
sessionStorage.removeItem(PENDING_SHARE_KEY)
return JSON.parse(raw) as SharePayload
} catch {
return null
}
}
/** In-app route to land on when content is shared *into* this app. */
export function shareLandingRoute(): string {
switch (SUITE_APP) {
case "mail":
return "/mail/inbox?compose=share"
case "drive":
return "/drive?upload=share"
case "contacts":
return "/contacts"
default:
return "/"
}
}
/** Share content out to other apps via the OS share sheet. */
export async function shareOut(payload: {
text?: string
url?: string
files?: string[]
mime?: string
}): Promise<void> {
if (!isTauriRuntime()) {
if (navigator.share && (payload.text || payload.url)) {
await navigator.share({ text: payload.text, url: payload.url })
}
return
}
await invoke("plugin:ulti-core|share_out", { payload })
}

84
lib/platform.ts Normal file
View File

@ -0,0 +1,84 @@
/**
* Platform detection shared by web and the Tauri mobile/native builds.
*
* - `IS_MOBILE_BUILD` is a *build-time* flag (set by `NEXT_PUBLIC_MOBILE=1`)
* used to gate the static export, drop server API routes, and switch auth to
* the native flow. It is statically analyzable so dead code is tree-shaken.
* - `isTauriRuntime()` is a *runtime* check (the same static bundle can be
* loaded by a browser during development, e.g. `pnpm dev`, even when built in
* mobile mode).
*/
/** True when the bundle was built for the Tauri native shells. */
export const IS_MOBILE_BUILD = process.env.NEXT_PUBLIC_MOBILE === "1"
/**
* The per-app product this build targets (UltiMail, UltiDrive, ). Drives the
* default start route, the deep-link scheme, and which sibling apps the suite
* launcher tries to open. Defaults to `mail` (the pilot).
*/
export type SuiteApp = "mail" | "drive" | "agenda" | "meet" | "chat" | "contacts"
export const SUITE_APP: SuiteApp =
(process.env.NEXT_PUBLIC_SUITE_APP as SuiteApp | undefined) ?? "mail"
/** Custom URL scheme for this app's deep links, e.g. `ultimail://`. */
export function appScheme(app: SuiteApp = SUITE_APP): string {
return `ulti${app}`
}
/** Default start route for an app shell. */
export function appStartRoute(app: SuiteApp = SUITE_APP): string {
switch (app) {
case "mail":
return "/mail"
case "drive":
return "/drive"
case "agenda":
return "/agenda"
case "meet":
return "/meet"
case "chat":
return "/chat"
case "contacts":
return "/contacts"
default:
return "/mail"
}
}
/**
* Map an in-app route to the suite app that "owns" it, so the launcher can open
* the sibling native app instead of navigating in-webview. Returns null for
* routes that should stay in the current app (settings, account, admin, ).
*/
export function suiteAppForRoute(route: string): SuiteApp | null {
const path = route.split("?")[0]
if (path.startsWith("/mail")) return "mail"
if (path.startsWith("/drive")) return "drive"
if (path.startsWith("/agenda")) return "agenda"
if (path.startsWith("/meet")) return "agenda" // UltiCal+UltiMeet share one app
if (path.startsWith("/chat")) return "chat"
if (path.startsWith("/contacts")) return "contacts"
return null
}
/** True only when actually executing inside a Tauri webview. */
export function isTauriRuntime(): boolean {
if (typeof window === "undefined") return false
return (
"__TAURI_INTERNALS__" in window ||
"__TAURI__" in window ||
// Tauri v2 exposes this in some configurations.
(window as { isTauri?: boolean }).isTauri === true
)
}
/**
* True when we should use the native auth + runtime-config code paths.
* Mobile builds always use native paths; a mobile build opened in a plain
* browser (dev) also uses them so the flow can be exercised without a device.
*/
export function useNativeRuntime(): boolean {
return IS_MOBILE_BUILD || isTauriRuntime()
}

225
lib/runtime-config/index.ts Normal file
View File

@ -0,0 +1,225 @@
"use client"
import { IS_MOBILE_BUILD, SUITE_APP, appScheme, useNativeRuntime } from "@/lib/platform"
import type { OidcRuntimeConfig, RuntimeConfig } from "./types"
export type { RuntimeConfig, OidcRuntimeConfig } from "./types"
export const RUNTIME_CONFIG_STORAGE_KEY = "ulti-runtime-config"
let current: RuntimeConfig | null = null
const listeners = new Set<(cfg: RuntimeConfig | null) => void>()
/** Native redirect URI for this app shell, e.g. `ultimail://oauth/callback`. */
export function nativeRedirectUri(): string {
return `${appScheme()}://oauth/callback`
}
function trimSlash(url: string): string {
return url.endsWith("/") ? url : `${url}/`
}
function stripTrailingSlash(url: string): string {
return url.replace(/\/+$/, "")
}
function wsFromOrigin(origin: string): string {
try {
const u = new URL(origin)
const proto = u.protocol === "https:" ? "wss:" : "ws:"
return `${proto}//${u.host}/ws`
} catch {
return `${origin.replace(/^http/, "ws")}/ws`
}
}
// ---------------------------------------------------------------------------
// Current config accessors
// ---------------------------------------------------------------------------
export function getRuntimeConfig(): RuntimeConfig | null {
if (current) return current
if (typeof window !== "undefined" && useNativeRuntime()) {
current = loadPersistedRuntimeConfig()
}
return current
}
export function setRuntimeConfig(cfg: RuntimeConfig | null) {
current = cfg
if (typeof window !== "undefined") {
try {
if (cfg) {
localStorage.setItem(RUNTIME_CONFIG_STORAGE_KEY, JSON.stringify(cfg))
} else {
localStorage.removeItem(RUNTIME_CONFIG_STORAGE_KEY)
}
} catch {
/* private mode / quota */
}
}
for (const fn of listeners) fn(cfg)
}
export function onRuntimeConfigChange(
fn: (cfg: RuntimeConfig | null) => void
): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
export function loadPersistedRuntimeConfig(): RuntimeConfig | null {
if (typeof window === "undefined") return null
try {
const raw = localStorage.getItem(RUNTIME_CONFIG_STORAGE_KEY)
if (!raw) return null
return JSON.parse(raw) as RuntimeConfig
} catch {
return null
}
}
// ---------------------------------------------------------------------------
// Resolved values (web env fallback vs native runtime config)
// ---------------------------------------------------------------------------
/** Backend base URL (absolute on native, proxied `/api/v1` on web). */
export function getApiBaseUrl(): string {
if (useNativeRuntime()) {
const cfg = getRuntimeConfig()
if (cfg) return cfg.apiBaseUrl
}
return process.env.NEXT_PUBLIC_API_URL ?? "/api/v1"
}
/** Realtime WS URL. */
export function getWsUrl(): string | null {
if (useNativeRuntime()) {
const cfg = getRuntimeConfig()
if (cfg) return cfg.wsUrl
}
if (process.env.NEXT_PUBLIC_WS_URL) return process.env.NEXT_PUBLIC_WS_URL
if (typeof window !== "undefined") {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:"
return `${proto}//${window.location.host}/ws`
}
return null
}
export function getOnlyOfficeUrl(): string | undefined {
if (useNativeRuntime()) {
const cfg = getRuntimeConfig()
if (cfg?.onlyOfficeUrl) return cfg.onlyOfficeUrl
}
return process.env.NEXT_PUBLIC_ONLYOFFICE_URL
}
export function getAiOrigin(): string | undefined {
if (useNativeRuntime()) {
const cfg = getRuntimeConfig()
if (cfg?.aiOrigin) return cfg.aiOrigin
}
return process.env.NEXT_PUBLIC_AI_ORIGIN
}
// ---------------------------------------------------------------------------
// Discovery: derive a full RuntimeConfig from an instance origin (server picker)
// ---------------------------------------------------------------------------
type OidcDiscoveryDoc = {
authorization_endpoint: string
token_endpoint: string
end_session_endpoint?: string
issuer?: string
}
/** Issuer path segment within an instance, configurable for exotic setups. */
function issuerPath(): string {
return (
process.env.NEXT_PUBLIC_OIDC_ISSUER_PATH ?? "/auth/application/o/ulti/"
)
}
function mobileClientId(): string {
// Shared public PKCE client across the suite enables cross-app SSO.
return process.env.NEXT_PUBLIC_OIDC_CLIENT_ID ?? "ulti-mobile"
}
export class RuntimeConfigError extends Error {}
/**
* Build a RuntimeConfig for an instance origin by running OIDC discovery.
* Used by the server picker (both the UltiSpace preset and self-hosted URLs).
*/
export async function deriveRuntimeConfig(
rawOrigin: string,
opts?: { label?: string }
): Promise<RuntimeConfig> {
let origin: string
try {
const u = new URL(
rawOrigin.includes("://") ? rawOrigin : `https://${rawOrigin}`
)
origin = stripTrailingSlash(u.origin)
} catch {
throw new RuntimeConfigError(`URL d'instance invalide : ${rawOrigin}`)
}
const issuer = trimSlash(`${origin}${issuerPath()}`)
const discoveryUrl = `${issuer}.well-known/openid-configuration`
let doc: OidcDiscoveryDoc
try {
const res = await fetch(discoveryUrl, { headers: { Accept: "application/json" } })
if (!res.ok) {
throw new RuntimeConfigError(
`Découverte OIDC échouée (${res.status}) sur ${origin}`
)
}
doc = (await res.json()) as OidcDiscoveryDoc
} catch (err) {
if (err instanceof RuntimeConfigError) throw err
throw new RuntimeConfigError(
`Impossible de joindre ${origin}. Vérifie l'URL et ta connexion.`
)
}
const oidc: OidcRuntimeConfig = {
issuer: doc.issuer ? trimSlash(doc.issuer) : issuer,
clientId: mobileClientId(),
redirectUri: nativeRedirectUri(),
authorizationEndpoint: doc.authorization_endpoint,
tokenEndpoint: doc.token_endpoint,
endSessionEndpoint:
doc.end_session_endpoint ?? `${issuer}end-session/`,
enrollmentUrl: `${origin}/auth/if/flow/ulti-enrollment/`,
}
return {
label: opts?.label ?? origin.replace(/^https?:\/\//, ""),
instanceOrigin: origin,
apiBaseUrl: `${origin}/api/v1`,
wsUrl: wsFromOrigin(origin),
oidc,
aiOrigin: origin,
aiPublicPath: process.env.NEXT_PUBLIC_AI_PUBLIC_PATH ?? "/ai",
onlyOfficeUrl: `${origin}/office`,
hocuspocusUrl: `${wsFromOrigin(origin).replace(/\/ws$/, "")}/collab`,
}
}
/** Origin of the hosted UltiSpace offering (preset option in the picker). */
export function ultiSpaceOrigin(): string {
return process.env.NEXT_PUBLIC_ULTISPACE_ORIGIN ?? "https://space.ulti.app"
}
/** Whether a server has been selected yet (native only). */
export function hasRuntimeConfig(): boolean {
return getRuntimeConfig() !== null
}
/** Convenience used by debug/logging. */
export const RUNTIME_INFO = {
isMobileBuild: IS_MOBILE_BUILD,
suiteApp: SUITE_APP,
}

Some files were not shown because too many files have changed in this diff Show More