This commit is contained in:
parent
10d5215a8c
commit
d6d18f911b
@ -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
5
.gitignore
vendored
@ -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/
|
||||
30
CLAUDE.md
30
CLAUDE.md
@ -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.
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { CompteSettingsSectionFromSegments } from "@/components/compte/compte-settings-section-view"
|
||||
|
||||
export function generateStaticParams() {
|
||||
return [{ section: [] }]
|
||||
}
|
||||
|
||||
export default async function AccountSectionPage({
|
||||
params,
|
||||
}: {
|
||||
|
||||
@ -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,
|
||||
}: {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
12
app/agenda/[[...segments]]/view.tsx
Normal file
12
app/agenda/[[...segments]]/view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
app/compte/[[...section]]/page.tsx
Normal file
17
app/compte/[[...section]]/page.tsx
Normal 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} />
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
7
app/contacts/[[...slug]]/view.tsx
Normal file
7
app/contacts/[[...slug]]/view.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { ContactsAppShell } from "@/components/gmail/contacts-page/contacts-app-shell"
|
||||
|
||||
export default function ContactsPage() {
|
||||
return <ContactsAppShell />
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
294
app/drive/(browser)/[[...segments]]/view.tsx
Normal file
294
app/drive/(browser)/[[...segments]]/view.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
530
app/globals.css
530
app/globals.css
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}: {
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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",
|
||||
)}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
80
components/auth/login-form.tsx
Normal file
80
components/auth/login-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
36
components/client-theme-applier.tsx
Normal file
36
components/client-theme-applier.tsx
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)}>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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 }))
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
103
components/mobile/mobile-layout-root.tsx
Normal file
103
components/mobile/mobile-layout-root.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
components/mobile/mobile-root-redirect.tsx
Normal file
26
components/mobile/mobile-root-redirect.tsx
Normal 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
|
||||
}
|
||||
128
components/mobile/native-auth-gate.tsx
Normal file
128
components/mobile/native-auth-gate.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
components/mobile/native-bridge-provider.tsx
Normal file
65
components/mobile/native-bridge-provider.tsx
Normal 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}</>
|
||||
}
|
||||
110
components/mobile/native-login.tsx
Normal file
110
components/mobile/native-login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
components/mobile/native-shell-chrome.tsx
Normal file
33
components/mobile/native-shell-chrome.tsx
Normal 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
|
||||
}
|
||||
147
components/mobile/server-picker.tsx
Normal file
147
components/mobile/server-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
components/suite/client-theme-shell.tsx
Normal file
19
components/suite/client-theme-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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é).
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
57
lib/api/contacts-bulk-import.ts
Normal file
57
lib/api/contacts-bulk-import.ts
Normal 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 }
|
||||
}
|
||||
@ -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}`}`
|
||||
}
|
||||
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
245
lib/auth/native-auth.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
56
lib/auth/native-session.ts
Normal file
56
lib/auth/native-session.ts
Normal 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
|
||||
}
|
||||
@ -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/"
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
3
lib/demo/demo-agenda-settings.ts
Normal file
3
lib/demo/demo-agenda-settings.ts
Normal 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
|
||||
@ -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, """)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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))]"
|
||||
|
||||
@ -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
68
lib/native/bridge.ts
Normal 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
52
lib/native/contacts.ts
Normal 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
53
lib/native/deep-links.ts
Normal 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
31
lib/native/inter-app.ts
Normal 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
81
lib/native/push.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
87
lib/native/secure-store.ts
Normal file
87
lib/native/secure-store.ts
Normal 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
80
lib/native/share.ts
Normal 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
84
lib/platform.ts
Normal 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
225
lib/runtime-config/index.ts
Normal 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
Loading…
Reference in New Issue
Block a user