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
|
NEXT_PUBLIC_HOCUSPOCUS_URL=ws://localhost/collab
|
||||||
# UltiAI (chemin proxy OpenWebUI — même origine)
|
# UltiAI (chemin proxy OpenWebUI — même origine)
|
||||||
NEXT_PUBLIC_AI_PUBLIC_PATH=/ai
|
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
|
# Dev Next.js (:3000) : charger l'iframe depuis nginx (:80) pour cookies session + proxy OpenWebUI
|
||||||
NEXT_PUBLIC_AI_ORIGIN=http://localhost
|
NEXT_PUBLIC_AI_ORIGIN=http://localhost
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -20,3 +20,6 @@ node_modules
|
|||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-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).
|
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`.
|
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"
|
import { CompteSettingsSectionFromSegments } from "@/components/compte/compte-settings-section-view"
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return [{ section: [] }]
|
||||||
|
}
|
||||||
|
|
||||||
export default async function AccountSectionPage({
|
export default async function AccountSectionPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { AdminSettingsSectionFromSegments } from "@/components/admin/settings/admin-settings-section-view"
|
import { AdminSettingsSectionFromSegments } from "@/components/admin/settings/admin-settings-section-view"
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return [{ section: [] }]
|
||||||
|
}
|
||||||
|
|
||||||
export default async function AdminSettingsSectionPage({
|
export default async function AdminSettingsSectionPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import { AgendaPage } from "@/components/agenda/agenda-page"
|
import AgendaView from "./view"
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return [{ segments: [] }]
|
||||||
|
}
|
||||||
|
|
||||||
export default function AgendaRoutePage() {
|
export default function AgendaRoutePage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AgendaPage />
|
<AgendaView />
|
||||||
</Suspense>
|
</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() {
|
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"
|
export function generateStaticParams() {
|
||||||
import { useParams, useSearchParams } from "next/navigation"
|
return [{ segments: [] }]
|
||||||
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() {
|
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 (
|
return (
|
||||||
<>
|
<Suspense fallback={null}>
|
||||||
<DriveHeader
|
<DriveBrowserView />
|
||||||
search={searchInput}
|
</Suspense>
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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%;
|
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 */
|
/* Mail UI: text selection only in fields and message previews */
|
||||||
.ultimail-app {
|
.ultimail-app {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
@ -467,6 +480,7 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: clamp(1rem, 3vw, 2rem);
|
padding: clamp(1rem, 3vw, 2rem);
|
||||||
|
pointer-events: auto;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 18% 20%, color-mix(in srgb, #1a73e8 32%, transparent) 0%, transparent 46%),
|
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%),
|
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 {
|
.app-first-launch-splash--hide {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-first-launch-splash__aurora {
|
.app-first-launch-splash__aurora {
|
||||||
@ -618,6 +633,209 @@ html:has(.ultimail-login) body {
|
|||||||
background-color: transparent !important;
|
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 ── */
|
/* ── Drive : pas de fond décoratif mail ── */
|
||||||
html[data-route-scope='drive']::before,
|
html[data-route-scope='drive']::before,
|
||||||
html:has([data-drive-app])::before {
|
html:has([data-drive-app])::before {
|
||||||
@ -634,8 +852,22 @@ html[data-route-scope='drive'] body {
|
|||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.ultimail-login-card-frame {
|
.ultimail-login-card-frame {
|
||||||
padding: 3px;
|
--login-card-outer-radius: 3rem;
|
||||||
border-radius: var(--radius-xl);
|
--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(
|
background: conic-gradient(
|
||||||
from 145deg,
|
from 145deg,
|
||||||
#1a73e8,
|
#1a73e8,
|
||||||
@ -644,12 +876,300 @@ html[data-route-scope='drive'] body {
|
|||||||
#ea4335,
|
#ea4335,
|
||||||
#1a73e8
|
#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'] {
|
.ultimail-login-card-frame > [data-slot='card'] {
|
||||||
border-width: 0;
|
position: relative;
|
||||||
border-radius: calc(var(--radius-xl) - 3px);
|
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 { Geist, Geist_Mono } from 'next/font/google'
|
||||||
import { Analytics } from '@vercel/analytics/next'
|
import { Analytics } from '@vercel/analytics/next'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
import { ClientThemeShell } from '@/components/suite/client-theme-shell'
|
||||||
import { ThemeInitScript } from '@/components/theme-init-script'
|
import { ThemeInitScript } from '@/components/theme-init-script'
|
||||||
import { FirstLaunchSplash } from '@/components/first-launch-splash'
|
import { FirstLaunchSplash } from '@/components/first-launch-splash'
|
||||||
import { QueryProvider } from '@/lib/api/query-provider'
|
import { QueryProvider } from '@/lib/api/query-provider'
|
||||||
import { AuthProvider } from '@/components/auth/auth-provider'
|
import { AuthProvider } from '@/components/auth/auth-provider'
|
||||||
import { SessionGuard } from '@/components/auth/session-guard'
|
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 { MailToaster } from '@/components/gmail/mail-toaster'
|
||||||
import { suiteRootMetadata } from '@/lib/suite/page-metadata'
|
import { suiteRootMetadata } from '@/lib/suite/page-metadata'
|
||||||
|
|
||||||
const geistSans = Geist({ subsets: ['latin'], variable: '--font-geist-sans' })
|
const geistSans = Geist({ subsets: ['latin'], variable: '--font-geist-sans' })
|
||||||
const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-geist-mono' })
|
const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-geist-mono' })
|
||||||
|
|
||||||
|
const isMobileBuild = process.env.NEXT_PUBLIC_MOBILE === '1'
|
||||||
|
|
||||||
export const metadata: Metadata = suiteRootMetadata()
|
export const metadata: Metadata = suiteRootMetadata()
|
||||||
|
|
||||||
/** Fit visible viewport on tablet/mobile; disable pinch/double-tap zoom on the shell. */
|
/** 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`}
|
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">
|
<body className="h-dvh max-h-dvh overflow-hidden bg-background font-sans antialiased touch-manipulation">
|
||||||
|
{isMobileBuild ? (
|
||||||
|
<MobileLayoutRoot>{children}</MobileLayoutRoot>
|
||||||
|
) : (
|
||||||
|
<ClientThemeShell>
|
||||||
<ThemeInitScript />
|
<ThemeInitScript />
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SessionGuard />
|
<SessionGuard />
|
||||||
|
<NativeBridgeProvider>
|
||||||
|
<NativeShellChrome />
|
||||||
|
<NativeAuthGate>
|
||||||
<FirstLaunchSplash>{children}</FirstLaunchSplash>
|
<FirstLaunchSplash>{children}</FirstLaunchSplash>
|
||||||
|
</NativeAuthGate>
|
||||||
|
</NativeBridgeProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
<MailToaster />
|
<MailToaster />
|
||||||
{process.env.NODE_ENV === 'production' && <Analytics />}
|
{process.env.NODE_ENV === 'production' && <Analytics />}
|
||||||
|
</ClientThemeShell>
|
||||||
|
)}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,22 +2,10 @@
|
|||||||
|
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { LoginForm } from "@/components/auth/login-form"
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
|
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
|
||||||
import { cn } from "@/lib/utils"
|
import { useNativeRuntime } from "@/lib/platform"
|
||||||
|
import { NativeLogin } from "@/components/mobile/native-login"
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
function LoginContent() {
|
function LoginContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
@ -26,53 +14,16 @@ function LoginContent() {
|
|||||||
const loginHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
|
const loginHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
|
||||||
const signupHref = getAuthentikEnrollmentUrl()
|
const signupHref = getAuthentikEnrollmentUrl()
|
||||||
|
|
||||||
|
if (useNativeRuntime()) {
|
||||||
|
return <NativeLogin returnTo={returnTo} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center px-4">
|
<LoginForm
|
||||||
<div className="ultimail-login-card-frame mx-auto w-full max-w-sm">
|
loginHref={loginHref}
|
||||||
<Card className={LOGIN_CARD_CLASS}>
|
signupHref={signupHref}
|
||||||
<CardHeader className="gap-4 px-0 text-center sm:px-0">
|
error={error}
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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. */
|
/** 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() {
|
export default function MailSegmentsPage() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,8 +34,6 @@ import { useMailStore } from "@/lib/stores/mail-store"
|
|||||||
import { useMailUiStore } from "@/lib/stores/mail-ui-store"
|
import { useMailUiStore } from "@/lib/stores/mail-ui-store"
|
||||||
import { DEFAULT_INBOX_TAB } from "@/lib/mail-url"
|
import { DEFAULT_INBOX_TAB } from "@/lib/mail-url"
|
||||||
import { cn } from "@/lib/utils"
|
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 { QuickSettingsRoot } from "@/components/gmail/quick-settings/quick-settings-root"
|
||||||
import { MailSettingsSync } from "@/components/gmail/mail-settings-sync"
|
import { MailSettingsSync } from "@/components/gmail/mail-settings-sync"
|
||||||
import { MailNavSync } from "@/components/gmail/mail-nav-sync"
|
import { MailNavSync } from "@/components/gmail/mail-nav-sync"
|
||||||
@ -235,7 +233,6 @@ export function MailAppShell({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
|
||||||
<ComposeProvider>
|
<ComposeProvider>
|
||||||
<ScheduledMailProvider>
|
<ScheduledMailProvider>
|
||||||
<EmailDragProvider>
|
<EmailDragProvider>
|
||||||
@ -249,7 +246,6 @@ export function MailAppShell({
|
|||||||
>
|
>
|
||||||
<MailAppInner />
|
<MailAppInner />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<MailThemeApplier />
|
|
||||||
<MailSettingsSync />
|
<MailSettingsSync />
|
||||||
<MailNavSync />
|
<MailNavSync />
|
||||||
<ComposeIdentitiesSync />
|
<ComposeIdentitiesSync />
|
||||||
@ -263,6 +259,5 @@ export function MailAppShell({
|
|||||||
</EmailDragProvider>
|
</EmailDragProvider>
|
||||||
</ScheduledMailProvider>
|
</ScheduledMailProvider>
|
||||||
</ComposeProvider>
|
</ComposeProvider>
|
||||||
</ThemeProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { LandingPage } from "@/components/landing/landing-page"
|
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"
|
import { suitePageMetadata } from "@/lib/suite/page-metadata"
|
||||||
|
|
||||||
export const metadata: Metadata = suitePageMetadata({
|
export const metadata: Metadata = suitePageMetadata({
|
||||||
@ -18,6 +20,10 @@ export default async function Home({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: HomeSearchParams
|
searchParams: HomeSearchParams
|
||||||
}) {
|
}) {
|
||||||
|
// Mobile static export: no server runtime, so never read searchParams here.
|
||||||
|
if (IS_MOBILE_BUILD) {
|
||||||
|
return <MobileRootRedirect />
|
||||||
|
}
|
||||||
const sp = await searchParams
|
const sp = await searchParams
|
||||||
const raw = sp.mail
|
const raw = sp.mail
|
||||||
const mail = Array.isArray(raw) ? raw[0] : raw
|
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 { MailSettingsSectionFromSegments } from "@/components/gmail/settings/mail-settings-section-view"
|
||||||
import { MAIL_SETTINGS_BASE_PATH } from "@/lib/mail-settings/settings-nav"
|
import { MAIL_SETTINGS_BASE_PATH } from "@/lib/mail-settings/settings-nav"
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return [{ section: [] }]
|
||||||
|
}
|
||||||
|
|
||||||
export default async function SettingsSectionPage({
|
export default async function SettingsSectionPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@ -25,12 +25,6 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
|
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() {
|
export function AgendaSection() {
|
||||||
const agenda = useOrgSettingsStore((s) => s.agenda)
|
const agenda = useOrgSettingsStore((s) => s.agenda)
|
||||||
const setAgenda = useOrgSettingsStore((s) => s.setAgenda)
|
const setAgenda = useOrgSettingsStore((s) => s.setAgenda)
|
||||||
@ -53,39 +47,11 @@ export function AgendaSection() {
|
|||||||
return (
|
return (
|
||||||
<OrgSettingsSection
|
<OrgSettingsSection
|
||||||
title={ULTICAL_APP_NAME}
|
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"
|
policySection="agenda"
|
||||||
beforeSave={() => setAgenda(draft)}
|
beforeSave={() => setAgenda(draft)}
|
||||||
>
|
>
|
||||||
<AutomationTabMasonry columns={2}>
|
<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
|
<SettingsCard
|
||||||
title="Visioconférence"
|
title="Visioconférence"
|
||||||
description="Fournisseur visio par défaut pour les événements."
|
description="Fournisseur visio par défaut pour les événements."
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useEffect, useLayoutEffect, type ReactNode } from "react"
|
import { useEffect, useLayoutEffect, type ReactNode } from "react"
|
||||||
import { AiChatPanel } from "@/components/ai/ai-chat-panel"
|
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 { AgendaQuickSettingsRoot } from "@/components/agenda/agenda-quick-settings-panel"
|
||||||
import { ComposeIdentitiesSync } from "@/components/gmail/compose-identities-sync"
|
import { ComposeIdentitiesSync } from "@/components/gmail/compose-identities-sync"
|
||||||
import { AgendaRouteRootProvider } from "@/lib/agenda/agenda-route-context"
|
import { AgendaRouteRootProvider } from "@/lib/agenda/agenda-route-context"
|
||||||
@ -49,7 +48,6 @@ export function AgendaAppShell({
|
|||||||
{children}
|
{children}
|
||||||
<AiChatPanel />
|
<AiChatPanel />
|
||||||
<ComposeIdentitiesSync />
|
<ComposeIdentitiesSync />
|
||||||
<AgendaOrgPolicySync />
|
|
||||||
<AgendaQuickSettingsRoot />
|
<AgendaQuickSettingsRoot />
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</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"
|
variant?: "panel" | "page"
|
||||||
onOpenThemeDialog?: () => void
|
onOpenThemeDialog?: () => void
|
||||||
}) {
|
}) {
|
||||||
const effective = useEffectiveAgendaSettings()
|
|
||||||
const isDemo = useIsDemoApp()
|
|
||||||
const { themeMode, setThemeMode } = useThemeModeControls()
|
const { themeMode, setThemeMode } = useThemeModeControls()
|
||||||
|
|
||||||
const defaultVideoProvider = useAgendaSettingsStore((s) => s.defaultVideoProvider)
|
const defaultVideoProvider = useAgendaSettingsStore((s) => s.defaultVideoProvider)
|
||||||
@ -170,7 +168,8 @@ export function AgendaSettingsFields({
|
|||||||
|
|
||||||
const identityOptions = useAgendaSettingsIdentityOptions()
|
const identityOptions = useAgendaSettingsIdentityOptions()
|
||||||
const destinationOptions = useAgendaSettingsDestinationOptions()
|
const destinationOptions = useAgendaSettingsDestinationOptions()
|
||||||
const activeTheme = effective.orgEnforcesTheme ? effective.themeMode : themeMode
|
const effective = useEffectiveAgendaSettings()
|
||||||
|
const isDemo = useIsDemoApp()
|
||||||
const activeProvider = effective.orgEnforcesVideoProvider
|
const activeProvider = effective.orgEnforcesVideoProvider
|
||||||
? effective.defaultVideoProvider
|
? effective.defaultVideoProvider
|
||||||
: defaultVideoProvider
|
: defaultVideoProvider
|
||||||
@ -202,7 +201,7 @@ export function AgendaSettingsFields({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shrink-0 text-xs text-[#1a73e8] hover:underline disabled:opacity-50"
|
className="shrink-0 text-xs text-[#1a73e8] hover:underline disabled:opacity-50"
|
||||||
disabled={effective.orgEnforcesTheme}
|
disabled={false}
|
||||||
onClick={onOpenThemeDialog}
|
onClick={onOpenThemeDialog}
|
||||||
>
|
>
|
||||||
Arrière-plan
|
Arrière-plan
|
||||||
@ -210,21 +209,15 @@ export function AgendaSettingsFields({
|
|||||||
) : null
|
) : 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">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
{THEME_OPTIONS.map((opt) => (
|
{THEME_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={effective.orgEnforcesTheme}
|
|
||||||
onClick={() => setThemeMode(opt.id)}
|
onClick={() => setThemeMode(opt.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border-2 p-1 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
"rounded-lg border-2 p-1 text-left transition-colors",
|
||||||
activeTheme === opt.id
|
themeMode === opt.id
|
||||||
? "border-primary bg-accent/60"
|
? "border-primary bg-accent/60"
|
||||||
: "border-border hover:border-muted-foreground/50 hover:bg-accent/40",
|
: "border-border hover:border-muted-foreground/50 hover:bg-accent/40",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -14,6 +14,14 @@ import {
|
|||||||
useSessionGuardStore,
|
useSessionGuardStore,
|
||||||
} from "@/lib/auth/session-guard-store"
|
} from "@/lib/auth/session-guard-store"
|
||||||
import { isAuthPublicPath } from "@/lib/auth/public-paths"
|
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_LEAD_MS = 5 * 60 * 1000
|
||||||
const REFRESH_CHECK_MS = 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 () => {
|
const syncSession = useCallback(async () => {
|
||||||
|
if (native) {
|
||||||
|
const token = await ensureNativeAccessToken()
|
||||||
|
if (token) return true
|
||||||
|
logout()
|
||||||
|
return false
|
||||||
|
}
|
||||||
const data = await fetchSession()
|
const data = await fetchSession()
|
||||||
if (data && applySession(data)) return true
|
if (data && applySession(data)) return true
|
||||||
logout()
|
logout()
|
||||||
return false
|
return false
|
||||||
}, [applySession, logout])
|
}, [applySession, logout, native])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@ -50,6 +66,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return
|
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()
|
const data = await fetchSession()
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|
||||||
@ -80,7 +111,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [applySession, logout, pathname])
|
}, [applySession, logout, pathname, native])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready || !isOidcConfigured()) return
|
if (!ready || !isOidcConfigured()) return
|
||||||
@ -100,6 +131,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!ready || !isOidcConfigured()) return
|
if (!ready || !isOidcConfigured()) return
|
||||||
if (isPublicPath(pathname)) return
|
if (isPublicPath(pathname)) return
|
||||||
if (isAuthenticated()) return
|
if (isAuthenticated()) return
|
||||||
|
// NativeAuthGate shows picker/login inline — avoid fighting redirects.
|
||||||
|
if (native) return
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void syncSession().then((ok) => {
|
void syncSession().then((ok) => {
|
||||||
@ -120,9 +153,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
export function useAuthLogout() {
|
export function useAuthLogout() {
|
||||||
const logout = useAuthStore((s) => s.logout)
|
const logout = useAuthStore((s) => s.logout)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const native = useNativeRuntime()
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
|
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" })
|
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
|
||||||
|
}
|
||||||
logout()
|
logout()
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem(AUTH_STORAGE_KEY)
|
localStorage.removeItem(AUTH_STORAGE_KEY)
|
||||||
|
|||||||
@ -10,8 +10,14 @@ export function LoginChrome({ children }: { children: React.ReactNode }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ultimail-login relative flex min-h-dvh flex-col bg-app-canvas">
|
<div className="ultimail-login relative flex min-h-dvh flex-col">
|
||||||
{children}
|
<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>
|
</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"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect } from "react"
|
import { useCallback, useEffect } from "react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { Icon } from "@iconify/react"
|
import { Icon } from "@iconify/react"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@ -16,6 +16,8 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { useSessionGuardStore } from "@/lib/auth/session-guard-store"
|
import { useSessionGuardStore } from "@/lib/auth/session-guard-store"
|
||||||
import { tryRefreshSession } from "@/lib/auth/session-sync"
|
import { tryRefreshSession } from "@/lib/auth/session-sync"
|
||||||
import { isAuthPublicPath } from "@/lib/auth/public-paths"
|
import { isAuthPublicPath } from "@/lib/auth/public-paths"
|
||||||
|
import { useNativeRuntime } from "@/lib/platform"
|
||||||
|
import { useAuthStore } from "@/lib/api/auth-store"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function isPublicPath(pathname: string) {
|
function isPublicPath(pathname: string) {
|
||||||
@ -24,10 +26,15 @@ function isPublicPath(pathname: string) {
|
|||||||
|
|
||||||
export function SessionGuard() {
|
export function SessionGuard() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const native = useNativeRuntime()
|
||||||
|
const authenticated = useAuthStore((s) => Boolean(s.accessToken))
|
||||||
const status = useSessionGuardStore((s) => s.status)
|
const status = useSessionGuardStore((s) => s.status)
|
||||||
|
|
||||||
const returnTo = pathname.startsWith("/") ? pathname : "/mail/inbox"
|
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 () => {
|
const retrySession = useCallback(async () => {
|
||||||
if (typeof navigator !== "undefined" && !navigator.onLine) return
|
if (typeof navigator !== "undefined" && !navigator.onLine) return
|
||||||
@ -46,6 +53,8 @@ export function SessionGuard() {
|
|||||||
}, [status, retrySession])
|
}, [status, retrySession])
|
||||||
|
|
||||||
if (isPublicPath(pathname)) return null
|
if (isPublicPath(pathname)) return null
|
||||||
|
// NativeAuthGate handles login — don't block touches with an expired modal.
|
||||||
|
if (native && !authenticated) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -85,7 +94,16 @@ export function SessionGuard() {
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogAction asChild>
|
<AlertDialogAction asChild>
|
||||||
|
{native ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.replace(loginHref)}
|
||||||
|
>
|
||||||
|
Se reconnecter
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<a href={loginHref}>Se reconnecter</a>
|
<a href={loginHref}>Se reconnecter</a>
|
||||||
|
)}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</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,
|
resolveAuthentikTheme,
|
||||||
type AuthentikUserSettingsTab,
|
type AuthentikUserSettingsTab,
|
||||||
} from "@/lib/auth/authentik-user-url"
|
} from "@/lib/auth/authentik-user-url"
|
||||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
import { useClientThemeStore } from "@/lib/stores/client-theme-store"
|
||||||
|
|
||||||
type CompteAuthentikPanelProps = {
|
type CompteAuthentikPanelProps = {
|
||||||
title: string
|
title: string
|
||||||
@ -29,7 +29,7 @@ export function CompteAuthentikPanel({
|
|||||||
actionLabel,
|
actionLabel,
|
||||||
icon,
|
icon,
|
||||||
}: CompteAuthentikPanelProps) {
|
}: CompteAuthentikPanelProps) {
|
||||||
const themeMode = useMailSettingsStore((s) => s.themeMode)
|
const themeMode = useClientThemeStore((s) => s.themeMode)
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const authentikTheme = resolveAuthentikTheme(themeMode, resolvedTheme)
|
const authentikTheme = resolveAuthentikTheme(themeMode, resolvedTheme)
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,13 @@ export const DEMO_USER = {
|
|||||||
email: "camille@demo.ulti",
|
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[] = [
|
export const DEMO_EMAILS: DemoEmail[] = [
|
||||||
{
|
{
|
||||||
id: "m1",
|
id: "m1",
|
||||||
|
|||||||
@ -11,8 +11,13 @@ import { DriveSearchBreadcrumb } from "@/components/drive/drive-search-breadcrum
|
|||||||
import type { DriveFileInfo } from "@/lib/api/types"
|
import type { DriveFileInfo } from "@/lib/api/types"
|
||||||
import type { DriveSearchState } from "@/lib/drive/drive-search"
|
import type { DriveSearchState } from "@/lib/drive/drive-search"
|
||||||
import type { DriveView } from "@/lib/drive/drive-url"
|
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 { 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 { useDriveUIStore } from "@/lib/stores/drive-ui-store"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@ -41,7 +46,10 @@ export function DriveBrowserChrome({
|
|||||||
}) {
|
}) {
|
||||||
const selectedPaths = useDriveUIStore((s) => s.selectedPaths)
|
const selectedPaths = useDriveUIStore((s) => s.selectedPaths)
|
||||||
const clearSelection = useDriveUIStore((s) => s.clearSelection)
|
const clearSelection = useDriveUIStore((s) => s.clearSelection)
|
||||||
|
const viewMode = useDriveSettingsStore((s) => s.viewMode)
|
||||||
const mutations = useDriveMutations()
|
const mutations = useDriveMutations()
|
||||||
|
const filterContentGap =
|
||||||
|
viewMode === "list" ? DRIVE_FILTER_LIST_CONTENT_GAP : DRIVE_FILTER_CONTENT_GAP
|
||||||
const selectedTargets = useMemo(
|
const selectedTargets = useMemo(
|
||||||
() => items.filter((f) => selectedPaths.has(f.path)),
|
() => items.filter((f) => selectedPaths.has(f.path)),
|
||||||
[items, selectedPaths]
|
[items, selectedPaths]
|
||||||
@ -65,7 +73,7 @@ export function DriveBrowserChrome({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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={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">
|
<div className="min-w-0 flex-1">
|
||||||
{showSearchBreadcrumb ? (
|
{showSearchBreadcrumb ? (
|
||||||
|
|||||||
@ -109,7 +109,7 @@ export function FileBrowser({
|
|||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
DRIVE_CARD_PAD_X
|
||||||
)}
|
)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { apiClient } from "@/lib/api/client"
|
import { apiClient } from "@/lib/api/client"
|
||||||
|
import { getOnlyOfficeUrl } from "@/lib/runtime-config"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft } from "lucide-react"
|
||||||
import { OnlyOfficeMount } from "@/components/drive/onlyoffice-mount"
|
import { OnlyOfficeMount } from "@/components/drive/onlyoffice-mount"
|
||||||
@ -83,7 +84,7 @@ export function OfficeEditor({
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
instanceSeq.current += 1
|
instanceSeq.current += 1
|
||||||
setConfig(res.config)
|
setConfig(res.config)
|
||||||
setServerUrl(res.serverUrl || process.env.NEXT_PUBLIC_ONLYOFFICE_URL || "")
|
setServerUrl(res.serverUrl || getOnlyOfficeUrl() || "")
|
||||||
setEditorId(`ultidrive-editor-${instanceSeq.current}`)
|
setEditorId(`ultidrive-editor-${instanceSeq.current}`)
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("Impossible de charger l’éditeur.")
|
if (!cancelled) setError("Impossible de charger l’éditeur.")
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { getOnlyOfficeUrl } from "@/lib/runtime-config"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft } from "lucide-react"
|
||||||
import { OnlyOfficeMount } from "@/components/drive/onlyoffice-mount"
|
import { OnlyOfficeMount } from "@/components/drive/onlyoffice-mount"
|
||||||
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
|
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
|
||||||
@ -88,7 +89,7 @@ export function PublicOfficeEditor({
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
instanceSeq.current += 1
|
instanceSeq.current += 1
|
||||||
setConfig(data.config)
|
setConfig(data.config)
|
||||||
setServerUrl(data.serverUrl || process.env.NEXT_PUBLIC_ONLYOFFICE_URL || "")
|
setServerUrl(data.serverUrl || getOnlyOfficeUrl() || "")
|
||||||
setEditorId(`ultidrive-public-editor-${instanceSeq.current}`)
|
setEditorId(`ultidrive-public-editor-${instanceSeq.current}`)
|
||||||
if (data.mode === "edit" || data.mode === "view") {
|
if (data.mode === "edit" || data.mode === "view") {
|
||||||
setResolvedMode(data.mode)
|
setResolvedMode(data.mode)
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import {
|
|||||||
DRIVE_CARD_PAD_X,
|
DRIVE_CARD_PAD_X,
|
||||||
DRIVE_CARD_SCROLL_PT,
|
DRIVE_CARD_SCROLL_PT,
|
||||||
DRIVE_FILTER_CONTENT_GAP,
|
DRIVE_FILTER_CONTENT_GAP,
|
||||||
|
DRIVE_FILTER_LIST_CONTENT_GAP,
|
||||||
} from "@/lib/drive/drive-chrome-classes"
|
} from "@/lib/drive/drive-chrome-classes"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { nextUntitledName } from "@/lib/drive/drive-default-name"
|
import { nextUntitledName } from "@/lib/drive/drive-default-name"
|
||||||
@ -56,6 +57,9 @@ export function PublicShareFolderView({
|
|||||||
const sortField = useDriveSettingsStore((s) => s.sortField)
|
const sortField = useDriveSettingsStore((s) => s.sortField)
|
||||||
const sortDir = useDriveSettingsStore((s) => s.sortDir)
|
const sortDir = useDriveSettingsStore((s) => s.sortDir)
|
||||||
const folderPlacement = useDriveSettingsStore((s) => s.folderPlacement)
|
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 filters = useDriveFiltersStore()
|
||||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@ -154,7 +158,7 @@ export function PublicShareFolderView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={DRIVE_BROWSER_CARD_CLASS}>
|
<div className={DRIVE_BROWSER_CARD_CLASS}>
|
||||||
<div className={cn("shrink-0", DRIVE_FILTER_CONTENT_GAP)}>
|
<div className={cn("shrink-0", filterContentGap)}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-3 py-2",
|
"flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-3 py-2",
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
type SuiteSplashApp,
|
type SuiteSplashApp,
|
||||||
} from "@/lib/suite/suite-app-splash"
|
} from "@/lib/suite/suite-app-splash"
|
||||||
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
|
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
|
||||||
|
import { IS_MOBILE_BUILD, useNativeRuntime } from "@/lib/platform"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const SPLASH_VISIBLE_MS = 1750
|
const SPLASH_VISIBLE_MS = 1750
|
||||||
@ -21,16 +22,21 @@ export function FirstLaunchSplash({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const native = useNativeRuntime()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [activeApp, setActiveApp] = useState<SuiteSplashApp | null>(() =>
|
const skipSplash = IS_MOBILE_BUILD || native
|
||||||
typeof window === "undefined"
|
const [activeApp, setActiveApp] = useState<SuiteSplashApp | null>(null)
|
||||||
? null
|
|
||||||
: shouldShowSuiteSplash(window.location.pathname)
|
|
||||||
)
|
|
||||||
const [isHiding, setIsHiding] = useState(false)
|
const [isHiding, setIsHiding] = useState(false)
|
||||||
const [isComplete, setIsComplete] = useState(() => activeApp === null)
|
const [isComplete, setIsComplete] = useState(true)
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
if (skipSplash) {
|
||||||
|
setActiveApp(null)
|
||||||
|
setIsComplete(true)
|
||||||
|
setIsHiding(false)
|
||||||
|
document.documentElement.dataset.splashSeen = "1"
|
||||||
|
return
|
||||||
|
}
|
||||||
const nextApp = shouldShowSuiteSplash(pathname)
|
const nextApp = shouldShowSuiteSplash(pathname)
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
root.dataset.splashApp = suiteSplashAppFromPath(pathname) ?? ""
|
root.dataset.splashApp = suiteSplashAppFromPath(pathname) ?? ""
|
||||||
@ -38,7 +44,7 @@ export function FirstLaunchSplash({
|
|||||||
setActiveApp(nextApp)
|
setActiveApp(nextApp)
|
||||||
setIsComplete(nextApp === null)
|
setIsComplete(nextApp === null)
|
||||||
setIsHiding(false)
|
setIsHiding(false)
|
||||||
}, [pathname])
|
}, [pathname, skipSplash])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeApp) return
|
if (!activeApp) return
|
||||||
@ -65,7 +71,7 @@ export function FirstLaunchSplash({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
{!isComplete && config ? (
|
{!skipSplash && !isComplete && config ? (
|
||||||
<div
|
<div
|
||||||
className={cn("app-first-launch-splash", isHiding && "app-first-launch-splash--hide")}
|
className={cn("app-first-launch-splash", isHiding && "app-first-launch-splash--hide")}
|
||||||
role="status"
|
role="status"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useRef, useState } from "react"
|
import { useRef, useState } from "react"
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -8,12 +9,12 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Info } from "lucide-react"
|
import { Info, Smartphone } from "lucide-react"
|
||||||
import { parseContactFile } from "@/lib/contacts/import-parsers"
|
import { parseContactFile, type ContactImportInput } from "@/lib/contacts/import-parsers"
|
||||||
import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations"
|
import { bulkImportContacts, importInputToBulk } from "@/lib/api/contacts-bulk-import"
|
||||||
import { fullContactToApiContact } from "@/lib/api/adapters"
|
import { deviceContactsAvailable, fetchDeviceContacts } from "@/lib/native/contacts"
|
||||||
|
import { invalidateContactListCache } from "@/lib/api/contact-list-cache"
|
||||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
import type { FullContact } from "@/lib/contacts/types"
|
|
||||||
import {
|
import {
|
||||||
CONTACTS_HEADING_TEXT,
|
CONTACTS_HEADING_TEXT,
|
||||||
CONTACTS_MUTED_TEXT,
|
CONTACTS_MUTED_TEXT,
|
||||||
@ -30,18 +31,20 @@ interface ImportDialogProps {
|
|||||||
|
|
||||||
export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
|
export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const createContactMutation = useCreateContact()
|
const queryClient = useQueryClient()
|
||||||
const { bookId } = useContactsList()
|
const { bookId } = useContactsList()
|
||||||
const [pendingFile, setPendingFile] = useState<File | null>(null)
|
const [pendingFile, setPendingFile] = useState<File | null>(null)
|
||||||
const [previewCount, setPreviewCount] = useState(0)
|
const [previewCount, setPreviewCount] = useState(0)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [result, setResult] = useState<string | null>(null)
|
||||||
|
|
||||||
function resetState() {
|
function resetState() {
|
||||||
setPendingFile(null)
|
setPendingFile(null)
|
||||||
setPreviewCount(0)
|
setPreviewCount(0)
|
||||||
setError(null)
|
setError(null)
|
||||||
setImporting(false)
|
setImporting(false)
|
||||||
|
setResult(null)
|
||||||
if (fileRef.current) fileRef.current.value = ""
|
if (fileRef.current) fileRef.current.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,34 +76,26 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImport() {
|
async function runImport(parsed: ContactImportInput[]) {
|
||||||
if (!pendingFile || previewCount === 0) return
|
|
||||||
|
|
||||||
setImporting(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const parsed = await parseContactFile(pendingFile)
|
|
||||||
if (parsed.length === 0) {
|
if (parsed.length === 0) {
|
||||||
setError("Aucun contact importé.")
|
setError("Aucun contact à importer.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (const partial of parsed) {
|
setImporting(true)
|
||||||
const fullContact: FullContact = {
|
setError(null)
|
||||||
id: crypto.randomUUID(),
|
setResult(null)
|
||||||
createdAt: Date.now(),
|
try {
|
||||||
updatedAt: Date.now(),
|
const { created, failed } = await bulkImportContacts(
|
||||||
...partial,
|
|
||||||
firstName: partial.firstName ?? "",
|
|
||||||
lastName: partial.lastName ?? "",
|
|
||||||
emails: partial.emails ?? [],
|
|
||||||
phones: partial.phones ?? [],
|
|
||||||
}
|
|
||||||
createContactMutation.mutate({
|
|
||||||
bookId,
|
bookId,
|
||||||
contact: fullContactToApiContact(fullContact),
|
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)
|
handleOpenChange(false)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError("L'import a échoué. Vérifiez le format du fichier.")
|
setError("L'import a échoué. Vérifiez le format du fichier.")
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
@ -128,6 +150,19 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
|
|||||||
Sélectionner un fichier
|
Sélectionner un fichier
|
||||||
</Button>
|
</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
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
@ -144,6 +179,7 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{result && <p className="text-sm text-amber-600">{result}</p>}
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>
|
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
|||||||
type PersistedSettings = Pick<
|
type PersistedSettings = Pick<
|
||||||
ReturnType<typeof useMailSettingsStore.getState>,
|
ReturnType<typeof useMailSettingsStore.getState>,
|
||||||
| "density"
|
| "density"
|
||||||
| "themeMode"
|
|
||||||
| "backgroundId"
|
| "backgroundId"
|
||||||
| "inboxSort"
|
| "inboxSort"
|
||||||
| "readingPane"
|
| "readingPane"
|
||||||
@ -29,7 +28,6 @@ type PersistedSettings = Pick<
|
|||||||
function pickPersisted(state: ReturnType<typeof useMailSettingsStore.getState>): PersistedSettings {
|
function pickPersisted(state: ReturnType<typeof useMailSettingsStore.getState>): PersistedSettings {
|
||||||
return {
|
return {
|
||||||
density: state.density,
|
density: state.density,
|
||||||
themeMode: state.themeMode,
|
|
||||||
backgroundId: state.backgroundId,
|
backgroundId: state.backgroundId,
|
||||||
inboxSort: state.inboxSort,
|
inboxSort: state.inboxSort,
|
||||||
readingPane: state.readingPane,
|
readingPane: state.readingPane,
|
||||||
@ -47,7 +45,6 @@ function diffPersisted(
|
|||||||
): Partial<PersistedSettings> {
|
): Partial<PersistedSettings> {
|
||||||
const changed: Partial<PersistedSettings> = {}
|
const changed: Partial<PersistedSettings> = {}
|
||||||
if (prev.density !== next.density) changed.density = next.density
|
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.backgroundId !== next.backgroundId) changed.backgroundId = next.backgroundId
|
||||||
if (prev.inboxSort !== next.inboxSort) changed.inboxSort = next.inboxSort
|
if (prev.inboxSort !== next.inboxSort) changed.inboxSort = next.inboxSort
|
||||||
if (prev.readingPane !== next.readingPane) changed.readingPane = next.readingPane
|
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
|
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[] = [
|
export const LANDING_APPS: LandingApp[] = [
|
||||||
{
|
{
|
||||||
name: "Ultimail",
|
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 sectionRef = useRef<HTMLElement>(null)
|
||||||
const [visible, setVisible] = useState(false)
|
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). */
|
/** Onglets dont l'iframe a été montée (état conservé au changement d'onglet). */
|
||||||
const [mounted, setMounted] = useState<Record<string, boolean>>({})
|
const [mounted, setMounted] = useState<Record<string, boolean>>({})
|
||||||
/** Incrément par onglet pour réinitialiser la démo (remount iframe). */
|
/** Incrément par onglet pour réinitialiser la démo (remount iframe). */
|
||||||
@ -83,6 +99,12 @@ export function LandingDemoSection() {
|
|||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [visible])
|
}, [visible])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!revealNonce) return
|
||||||
|
setVisible(true)
|
||||||
|
setMounted((prev) => ({ ...prev, [activeTab]: true }))
|
||||||
|
}, [revealNonce, activeTab])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
setMounted((prev) => (prev[activeTab] ? prev : { ...prev, [activeTab]: true }))
|
setMounted((prev) => (prev[activeTab] ? prev : { ...prev, [activeTab]: true }))
|
||||||
|
|||||||
@ -3,122 +3,73 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Icon } from "@iconify/react"
|
import { Icon } from "@iconify/react"
|
||||||
import { LandingReveal } from "@/components/landing/landing-reveal"
|
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 { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
||||||
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
|
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)
|
const apps = LANDING_APPS.filter((app) => app.href)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3 sm:gap-4">
|
<div className="flex flex-wrap items-center justify-center gap-3 sm:gap-4">
|
||||||
{apps.map((app, index) => (
|
{apps.map((app, index) => {
|
||||||
<Link
|
const tileClass =
|
||||||
key={app.name}
|
"landing-dock-tile landing-glass group flex size-14 items-center justify-center rounded-2xl transition-transform hover:scale-110 sm:size-16"
|
||||||
href={app.href!}
|
const tileStyle = { "--float-delay": `${index * 0.55}s` } as React.CSSProperties
|
||||||
title={app.name}
|
const icon = (
|
||||||
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}
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src={app.icon}
|
src={app.icon}
|
||||||
alt={app.name}
|
alt={app.name}
|
||||||
className="size-8 object-contain transition-transform group-hover:scale-110 sm:size-9"
|
className="size-8 object-contain transition-transform group-hover:scale-110 sm:size-9"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (authenticated) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={app.name}
|
||||||
|
href={app.href!}
|
||||||
|
title={app.name}
|
||||||
|
className={tileClass}
|
||||||
|
style={tileStyle}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
|
||||||
</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 (
|
return (
|
||||||
<div
|
<button
|
||||||
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)]"
|
key={app.name}
|
||||||
aria-hidden
|
type="button"
|
||||||
|
title={app.name}
|
||||||
|
className={tileClass}
|
||||||
|
style={tileStyle}
|
||||||
|
onClick={() => {
|
||||||
|
const demoTab = app.href ? (LANDING_APP_DEMO_TAB[app.href] ?? null) : null
|
||||||
|
onOpenDemo(demoTab)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 px-3 py-2">
|
{icon}
|
||||||
<span className="size-2.5 rounded-full bg-[#ff5f57]" />
|
</button>
|
||||||
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LandingHero() {
|
export function LandingHero({ onOpenDemo }: { onOpenDemo: (demoTabId: string | null) => void }) {
|
||||||
const identity = useChromeIdentity()
|
const identity = useChromeIdentity()
|
||||||
|
|
||||||
return (
|
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">
|
<div className="mx-auto flex w-full max-w-5xl flex-col items-center gap-8 text-center">
|
||||||
<LandingReveal>
|
<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">
|
<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>
|
||||||
|
|
||||||
<LandingReveal delay={0.32} className="w-full">
|
<LandingReveal delay={0.32} className="w-full">
|
||||||
<HeroDock />
|
<HeroDock authenticated={Boolean(identity)} onOpenDemo={onOpenDemo} />
|
||||||
</LandingReveal>
|
|
||||||
|
|
||||||
<LandingReveal delay={0.4} className="w-full pt-6">
|
|
||||||
<HeroPreview />
|
|
||||||
</LandingReveal>
|
</LandingReveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useRef, useState } from "react"
|
import { useCallback, useRef, useState } from "react"
|
||||||
import { LandingDemoSection } from "@/components/landing/landing-demo"
|
import { LandingDemoSection } from "@/components/landing/landing-demo"
|
||||||
import { LandingHeader } from "@/components/landing/landing-header"
|
import { LandingHeader } from "@/components/landing/landing-header"
|
||||||
import { LandingHero } from "@/components/landing/landing-hero"
|
import { LandingHero } from "@/components/landing/landing-hero"
|
||||||
import { LandingThemeApplier } from "@/components/landing/landing-theme-applier"
|
|
||||||
import {
|
import {
|
||||||
LandingAppsSection,
|
LandingAppsSection,
|
||||||
LandingFeaturesSection,
|
LandingFeaturesSection,
|
||||||
@ -16,6 +15,16 @@ import {
|
|||||||
export function LandingPage() {
|
export function LandingPage() {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const [scrolled, setScrolled] = useState(false)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -26,7 +35,6 @@ export function LandingPage() {
|
|||||||
setScrolled((prev) => (top > 8 ? true : top <= 2 ? false : prev))
|
setScrolled((prev) => (top > 8 ? true : top <= 2 ? false : prev))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LandingThemeApplier />
|
|
||||||
<div className="landing-backdrop" aria-hidden>
|
<div className="landing-backdrop" aria-hidden>
|
||||||
<div className="landing-orb landing-orb--a" />
|
<div className="landing-orb landing-orb--a" />
|
||||||
<div className="landing-orb landing-orb--b" />
|
<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">
|
<div className="relative z-10 flex min-h-full flex-col">
|
||||||
<LandingHeader scrolled={scrolled} />
|
<LandingHeader scrolled={scrolled} />
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
<LandingHero />
|
<LandingHero onOpenDemo={openDemo} />
|
||||||
<LandingIntegrationsSection />
|
<LandingIntegrationsSection />
|
||||||
<LandingDemoSection />
|
<LandingDemoSection
|
||||||
|
activeTab={demoActiveTab}
|
||||||
|
onActiveTabChange={setDemoActiveTab}
|
||||||
|
revealNonce={demoRevealNonce}
|
||||||
|
/>
|
||||||
<LandingAppsSection />
|
<LandingAppsSection />
|
||||||
<LandingFeaturesSection />
|
<LandingFeaturesSection />
|
||||||
<LandingSovereigntySection />
|
<LandingSovereigntySection />
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react"
|
import {
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface LandingRevealProps {
|
interface LandingRevealProps {
|
||||||
@ -11,6 +16,11 @@ interface LandingRevealProps {
|
|||||||
as?: "div" | "section" | "li" | "span"
|
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). */
|
/** Révèle son contenu à l'entrée dans le viewport (une seule fois). */
|
||||||
export function LandingReveal({
|
export function LandingReveal({
|
||||||
children,
|
children,
|
||||||
@ -19,15 +29,25 @@ export function LandingReveal({
|
|||||||
as: Tag = "div",
|
as: Tag = "div",
|
||||||
}: LandingRevealProps) {
|
}: LandingRevealProps) {
|
||||||
const ref = useRef<HTMLElement | null>(null)
|
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
|
const node = ref.current
|
||||||
if (!node || revealed) return
|
if (!node) return
|
||||||
|
|
||||||
if (typeof IntersectionObserver === "undefined") {
|
if (typeof IntersectionObserver === "undefined") {
|
||||||
setRevealed(true)
|
setRevealed(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isInRevealViewport(node)) {
|
||||||
|
setRevealed(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setRevealed(false)
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries.some((entry) => entry.isIntersecting)) {
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
@ -39,13 +59,16 @@ export function LandingReveal({
|
|||||||
)
|
)
|
||||||
observer.observe(node)
|
observer.observe(node)
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [revealed])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
ref={ref as never}
|
ref={ref as never}
|
||||||
className={cn("landing-reveal", className)}
|
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}
|
style={delay ? ({ "--reveal-delay": `${delay}s` } as React.CSSProperties) : undefined}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -268,7 +268,7 @@ function IntegrationsTrack() {
|
|||||||
|
|
||||||
export function LandingIntegrationsSection() {
|
export function LandingIntegrationsSection() {
|
||||||
return (
|
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">
|
<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)]">
|
<p className="text-center text-xs font-semibold uppercase tracking-widest text-[var(--landing-muted)]">
|
||||||
S'intègre avec vos standards ouverts
|
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,
|
type FavoriteApp,
|
||||||
} from "@/lib/suite/favorite-apps"
|
} from "@/lib/suite/favorite-apps"
|
||||||
import { MAIL_SETTINGS_BASE_PATH } from "@/lib/mail-settings/settings-nav"
|
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 { SUITE_HEADER_DROPDOWN_CLASS, SUITE_ICON_BTN } from "@/lib/suite/suite-chrome-classes"
|
||||||
import { cn } from "@/lib/utils"
|
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 (
|
return (
|
||||||
<Link href={app.href} className={FAVORITE_TILE_CLASS} onClick={onNavigate}>
|
<Link href={app.href} className={FAVORITE_TILE_CLASS} onClick={onNavigate}>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@ -1,14 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ThemeProvider } from "@/components/theme-provider"
|
/** @deprecated Préférer ClientThemeShell au layout racine ; conservé pour compat imports. */
|
||||||
import { MailThemeApplier } from "@/components/gmail/mail-theme-applier"
|
|
||||||
|
|
||||||
/** Thème clair/sombre ; fond décoratif réservé aux routes /mail. */
|
|
||||||
export function SuiteThemeShell({ children }: { children: React.ReactNode }) {
|
export function SuiteThemeShell({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return children
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
|
||||||
{children}
|
|
||||||
<MailThemeApplier />
|
|
||||||
</ThemeProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,29 +47,37 @@ export const THEME_INIT_SCRIPT = `
|
|||||||
document.documentElement.dataset.splashApp = splashApp || "";
|
document.documentElement.dataset.splashApp = splashApp || "";
|
||||||
document.documentElement.dataset.splashSeen = splashSeen ? "1" : "0";
|
document.documentElement.dataset.splashSeen = splashSeen ? "1" : "0";
|
||||||
|
|
||||||
var isDemo = path === "/demo" || path.indexOf("/demo/") === 0;
|
|
||||||
var isLanding = path === "/";
|
|
||||||
var mode = "system";
|
var mode = "system";
|
||||||
var bgId = null;
|
var bgId = null;
|
||||||
|
|
||||||
if (isDemo) {
|
function readPersistedThemeMode(raw) {
|
||||||
var demoRaw = localStorage.getItem("ultimail-demo-theme");
|
if (!raw) return null;
|
||||||
mode = "system";
|
|
||||||
if (demoRaw) {
|
|
||||||
try {
|
try {
|
||||||
var demoParsed = JSON.parse(demoRaw);
|
|
||||||
mode = (demoParsed.state || demoParsed).themeMode || "system";
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
} else if (isLanding) {
|
|
||||||
mode = "system";
|
|
||||||
} else {
|
|
||||||
var raw = localStorage.getItem("ultimail-mail-settings");
|
|
||||||
if (!raw) return;
|
|
||||||
var parsed = JSON.parse(raw);
|
var parsed = JSON.parse(raw);
|
||||||
var state = parsed.state || parsed;
|
var state = parsed.state || parsed;
|
||||||
mode = state.themeMode || "system";
|
return state.themeMode || null;
|
||||||
bgId = state.backgroundId;
|
} 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 mailParsed = JSON.parse(mailSettingsRaw);
|
||||||
|
var mailState = mailParsed.state || mailParsed;
|
||||||
|
bgId = mailState.backgroundId;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolved =
|
var resolved =
|
||||||
@ -120,6 +128,16 @@ export const THEME_INIT_SCRIPT = `
|
|||||||
})();
|
})();
|
||||||
`.trim()
|
`.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
|
* 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é).
|
* inerte pour éviter l'avertissement React 19 (le script a déjà tourné).
|
||||||
|
|||||||
@ -41,6 +41,7 @@ function Button({
|
|||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
|
type,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'button'> &
|
}: React.ComponentProps<'button'> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
@ -51,6 +52,7 @@ function Button({
|
|||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
|
type={asChild ? undefined : (type ?? 'button')}
|
||||||
className={cn(buttonVariants({ variant, size }), className)}
|
className={cn(buttonVariants({ variant, size }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { useCurrentUser } from "@/lib/api/hooks/use-current-user"
|
import { useCurrentUser } from "@/lib/api/hooks/use-current-user"
|
||||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_AGENDA_ORG_SETTINGS,
|
DEFAULT_AGENDA_ORG_SETTINGS,
|
||||||
DEFAULT_AGENDA_USER_SETTINGS,
|
DEFAULT_AGENDA_USER_SETTINGS,
|
||||||
@ -17,10 +16,13 @@ import {
|
|||||||
normalizeAutoImportInvitationSources,
|
normalizeAutoImportInvitationSources,
|
||||||
} from "@/lib/agenda/agenda-destination-identities"
|
} from "@/lib/agenda/agenda-destination-identities"
|
||||||
import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
|
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 & {
|
export type EffectiveAgendaSettings = AgendaUserSettings & {
|
||||||
themeMode: ReturnType<typeof useMailSettingsStore.getState>["themeMode"]
|
|
||||||
orgEnforcesTheme: boolean
|
|
||||||
orgEnforcesVideoProvider: boolean
|
orgEnforcesVideoProvider: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,6 +63,7 @@ function clampMinutes(value: number, min: number, max: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useEffectiveAgendaSettings(): EffectiveAgendaSettings {
|
export function useEffectiveAgendaSettings(): EffectiveAgendaSettings {
|
||||||
|
const isDemoAgenda = useIsDemoAgenda()
|
||||||
const defaultVideoProvider = useAgendaSettingsStore((s) => s.defaultVideoProvider)
|
const defaultVideoProvider = useAgendaSettingsStore((s) => s.defaultVideoProvider)
|
||||||
const videoProviderApiKeys = useAgendaSettingsStore((s) => s.videoProviderApiKeys)
|
const videoProviderApiKeys = useAgendaSettingsStore((s) => s.videoProviderApiKeys)
|
||||||
const defaultInvitationIdentityKey = useAgendaSettingsStore(
|
const defaultInvitationIdentityKey = useAgendaSettingsStore(
|
||||||
@ -81,13 +84,10 @@ export function useEffectiveAgendaSettings(): EffectiveAgendaSettings {
|
|||||||
const timeFormat = useAgendaSettingsStore((s) => s.timeFormat)
|
const timeFormat = useAgendaSettingsStore((s) => s.timeFormat)
|
||||||
const dragSnapMinutes = useAgendaSettingsStore((s) => s.dragSnapMinutes)
|
const dragSnapMinutes = useAgendaSettingsStore((s) => s.dragSnapMinutes)
|
||||||
const buttonSnapMinutes = useAgendaSettingsStore((s) => s.buttonSnapMinutes)
|
const buttonSnapMinutes = useAgendaSettingsStore((s) => s.buttonSnapMinutes)
|
||||||
const themeMode = useMailSettingsStore((s) => s.themeMode)
|
|
||||||
const { data: user } = useCurrentUser()
|
const { data: user } = useCurrentUser()
|
||||||
|
|
||||||
const org = user?.org_agenda ?? DEFAULT_ORG_AGENDA_PUBLIC
|
const org = user?.org_agenda ?? DEFAULT_ORG_AGENDA_PUBLIC
|
||||||
const orgEnforcesTheme = org.enforce_org_theme
|
|
||||||
const orgEnforcesVideoProvider = org.enforce_org_video_provider
|
const orgEnforcesVideoProvider = org.enforce_org_video_provider
|
||||||
const orgDefaultTheme = org.default_theme_mode
|
|
||||||
const orgDefaultVideoProvider = org.default_video_provider
|
const orgDefaultVideoProvider = org.default_video_provider
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
@ -99,8 +99,12 @@ export function useEffectiveAgendaSettings(): EffectiveAgendaSettings {
|
|||||||
invitationImportExclusions,
|
invitationImportExclusions,
|
||||||
weekStart,
|
weekStart,
|
||||||
defaultQuickDurationMinutes,
|
defaultQuickDurationMinutes,
|
||||||
visibleHoursStart,
|
visibleHoursStart: isDemoAgenda
|
||||||
visibleHoursEnd,
|
? DEMO_AGENDA_VISIBLE_HOURS_START
|
||||||
|
: visibleHoursStart,
|
||||||
|
visibleHoursEnd: isDemoAgenda
|
||||||
|
? DEMO_AGENDA_VISIBLE_HOURS_END
|
||||||
|
: visibleHoursEnd,
|
||||||
timeFormat,
|
timeFormat,
|
||||||
dragSnapMinutes,
|
dragSnapMinutes,
|
||||||
buttonSnapMinutes,
|
buttonSnapMinutes,
|
||||||
@ -112,8 +116,6 @@ export function useEffectiveAgendaSettings(): EffectiveAgendaSettings {
|
|||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
defaultVideoProvider: provider,
|
defaultVideoProvider: provider,
|
||||||
themeMode: orgEnforcesTheme ? orgDefaultTheme : themeMode,
|
|
||||||
orgEnforcesTheme,
|
|
||||||
orgEnforcesVideoProvider,
|
orgEnforcesVideoProvider,
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@ -126,13 +128,11 @@ export function useEffectiveAgendaSettings(): EffectiveAgendaSettings {
|
|||||||
defaultQuickDurationMinutes,
|
defaultQuickDurationMinutes,
|
||||||
visibleHoursStart,
|
visibleHoursStart,
|
||||||
visibleHoursEnd,
|
visibleHoursEnd,
|
||||||
|
isDemoAgenda,
|
||||||
timeFormat,
|
timeFormat,
|
||||||
dragSnapMinutes,
|
dragSnapMinutes,
|
||||||
buttonSnapMinutes,
|
buttonSnapMinutes,
|
||||||
themeMode,
|
|
||||||
orgEnforcesTheme,
|
|
||||||
orgEnforcesVideoProvider,
|
orgEnforcesVideoProvider,
|
||||||
orgDefaultTheme,
|
|
||||||
orgDefaultVideoProvider,
|
orgDefaultVideoProvider,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
|
import { getAiOrigin } from "@/lib/runtime-config"
|
||||||
|
|
||||||
/** Public path for OpenWebUI (default /ai). */
|
/** Public path for OpenWebUI (default /ai). */
|
||||||
export function resolveAiEmbedBase(publicPath = "/ai"): string {
|
export function resolveAiEmbedBase(publicPath = "/ai"): string {
|
||||||
const path = (publicPath || "/ai").replace(/\/$/, "") || "/ai"
|
const path = (publicPath || "/ai").replace(/\/$/, "") || "/ai"
|
||||||
const normalized = path.startsWith("/") ? path : `/${path}`
|
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
|
return origin ? `${origin}${normalized}` : normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { ApiError } from "./types"
|
|||||||
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
|
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
|
||||||
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized"
|
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized"
|
||||||
import { isSessionExpired } from "@/lib/auth/session-guard-store"
|
import { isSessionExpired } from "@/lib/auth/session-guard-store"
|
||||||
|
import { getApiBaseUrl } from "@/lib/runtime-config"
|
||||||
|
|
||||||
export class OfflineError extends Error {
|
export class OfflineError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -29,12 +30,18 @@ const DEFAULT_RETRIES = 3
|
|||||||
const BASE_DELAY = 1000
|
const BASE_DELAY = 1000
|
||||||
|
|
||||||
class ApiClient {
|
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 {
|
private resolveUrl(path: string): URL {
|
||||||
const base = this.baseUrl.startsWith("http")
|
const baseUrl = this.resolveBaseUrl()
|
||||||
? this.baseUrl
|
const base = baseUrl.startsWith("http")
|
||||||
: `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${this.baseUrl}`
|
? baseUrl
|
||||||
|
: `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${baseUrl}`
|
||||||
// Absolute path (leading /) would replace /api/v1 — keep base path segment.
|
// Absolute path (leading /) would replace /api/v1 — keep base path segment.
|
||||||
const normalizedBase = base.endsWith("/") ? base : `${base}/`
|
const normalizedBase = base.endsWith("/") ? base : `${base}/`
|
||||||
const normalizedPath = path.startsWith("/") ? path.slice(1) : path
|
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,
|
withPathRefBody,
|
||||||
} from "@/lib/api/drive-roots"
|
} from "@/lib/api/drive-roots"
|
||||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||||
|
import { getApiBaseUrl } from "@/lib/runtime-config"
|
||||||
import { useDemoDrive, useIsDemoDrive } from "@/lib/demo/demo-drive-context"
|
import { useDemoDrive, useIsDemoDrive } from "@/lib/demo/demo-drive-context"
|
||||||
import { DEMO_DRIVE_QUERY_ROOT } from "@/lib/demo/demo-drive-bootstrap"
|
import { DEMO_DRIVE_QUERY_ROOT } from "@/lib/demo/demo-drive-bootstrap"
|
||||||
import { useDemoDriveStore } from "@/lib/demo/demo-drive-store"
|
import { useDemoDriveStore } from "@/lib/demo/demo-drive-store"
|
||||||
@ -544,7 +545,7 @@ export function useDriveMountMutations() {
|
|||||||
|
|
||||||
/** @deprecated Use openDriveFileInNewTab / downloadDriveFile — API requires Authorization. */
|
/** @deprecated Use openDriveFileInNewTab / downloadDriveFile — API requires Authorization. */
|
||||||
export function fileDownloadUrl(path: string): string {
|
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}`}`
|
return `${base}/drive/download${path.startsWith("/") ? path : `/${path}`}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import type { useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
|
import type { useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
|
||||||
|
import { getApiBaseUrl } from "@/lib/runtime-config"
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "/api/v1"
|
|
||||||
|
|
||||||
function withPassword(url: string, password?: string) {
|
function withPassword(url: string, password?: string) {
|
||||||
if (!password) return url
|
if (!password) return url
|
||||||
@ -14,7 +13,7 @@ function fileApiPath(token: string, filePath: string) {
|
|||||||
.split("/")
|
.split("/")
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map(encodeURIComponent)
|
.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) {
|
function folderApiPath(token: string, folderPath: string) {
|
||||||
@ -23,7 +22,7 @@ function folderApiPath(token: string, folderPath: string) {
|
|||||||
.split("/")
|
.split("/")
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map(encodeURIComponent)
|
.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(
|
export async function uploadPublicShareFile(
|
||||||
@ -71,7 +70,7 @@ export async function renamePublicShareItem(
|
|||||||
newName: string,
|
newName: string,
|
||||||
password?: 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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ path: filePath, new_name: newName }),
|
body: JSON.stringify({ path: filePath, new_name: newName }),
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { useAuthStore } from "@/lib/api/auth-store"
|
import { useAuthStore } from "@/lib/api/auth-store"
|
||||||
|
import { getApiBaseUrl } from "@/lib/runtime-config"
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "/api/v1"
|
|
||||||
|
|
||||||
export async function uploadFile(targetPath: string, file: File, onProgress?: (pct: number) => void) {
|
export async function uploadFile(targetPath: string, file: File, onProgress?: (pct: number) => void) {
|
||||||
const token = useAuthStore.getState().accessToken
|
const token = useAuthStore.getState().accessToken
|
||||||
const path = targetPath.startsWith("/") ? targetPath : `/${targetPath}`
|
const path = targetPath.startsWith("/") ? targetPath : `/${targetPath}`
|
||||||
const url = `${API_BASE}/drive/files${path}`
|
const url = `${getApiBaseUrl()}/drive/files${path}`
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { WsEvent, WsEventType, WsMailPayload } from "./types"
|
|||||||
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
|
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
|
||||||
import { useAuthStore } from "./auth-store"
|
import { useAuthStore } from "./auth-store"
|
||||||
import { useIsDemoApp } from "@/lib/demo/use-is-demo-app"
|
import { useIsDemoApp } from "@/lib/demo/use-is-demo-app"
|
||||||
|
import { getWsUrl } from "@/lib/runtime-config"
|
||||||
|
|
||||||
export type WsEventListener = (evt: WsEvent) => void
|
export type WsEventListener = (evt: WsEvent) => void
|
||||||
|
|
||||||
@ -40,11 +41,8 @@ class WebSocketManager {
|
|||||||
connect(token: string) {
|
connect(token: string) {
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) return
|
if (this.ws?.readyState === WebSocket.OPEN) return
|
||||||
|
|
||||||
const baseUrl =
|
const baseUrl = getWsUrl() ?? ""
|
||||||
process.env.NEXT_PUBLIC_WS_URL ??
|
if (!baseUrl) return
|
||||||
(typeof window !== "undefined"
|
|
||||||
? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`
|
|
||||||
: "")
|
|
||||||
|
|
||||||
const url = `${baseUrl}?token=${encodeURIComponent(token)}&since=${this.lastSeq}`
|
const url = `${baseUrl}?token=${encodeURIComponent(token)}&since=${this.lastSeq}`
|
||||||
this.ws = new WebSocket(url)
|
this.ws = new WebSocket(url)
|
||||||
|
|||||||
@ -1,12 +1,21 @@
|
|||||||
import { useAuthStore } from "@/lib/api/auth-store"
|
import { useAuthStore } from "@/lib/api/auth-store"
|
||||||
import { fetchSession, applySessionToStore } from "@/lib/auth/session-sync"
|
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
|
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> {
|
export async function ensureAccessToken(): Promise<string | null> {
|
||||||
if (!syncPromise) {
|
if (!syncPromise) {
|
||||||
syncPromise = (async () => {
|
syncPromise = (async () => {
|
||||||
|
if (useNativeRuntime()) {
|
||||||
|
return ensureNativeAccessToken()
|
||||||
|
}
|
||||||
const data = await fetchSession()
|
const data = await fetchSession()
|
||||||
if (data && applySessionToStore(data)) {
|
if (data && applySessionToStore(data)) {
|
||||||
return useAuthStore.getState().accessToken
|
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). */
|
/** 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) {
|
function trimSlash(url: string) {
|
||||||
return url.endsWith("/") ? url : `${url}/`
|
return url.endsWith("/") ? url : `${url}/`
|
||||||
@ -30,8 +32,16 @@ export function getAuthentikBase(): string {
|
|||||||
return issuer.replace(/application\/o\/[^/]+\/?$/, "")
|
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 {
|
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 = {
|
type OidcDiscovery = {
|
||||||
@ -142,6 +152,21 @@ function toServerEndpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getPublicOidcConfig(): OidcConfig {
|
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(
|
const issuer = trimSlash(
|
||||||
process.env.NEXT_PUBLIC_OIDC_ISSUER ??
|
process.env.NEXT_PUBLIC_OIDC_ISSUER ??
|
||||||
"http://localhost/auth/application/o/ulti/"
|
"http://localhost/auth/application/o/ulti/"
|
||||||
|
|||||||
@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
import { useLayoutEffect } from "react"
|
import { useLayoutEffect } from "react"
|
||||||
import { useQueryClient } from "@tanstack/react-query"
|
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 { 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
|
export const DEMO_AGENDA_QUERY_ROOT = ["demo", "agenda"] as const
|
||||||
|
|
||||||
@ -13,6 +18,11 @@ export function DemoAgendaBootstrap() {
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
useSessionGuardStore.getState().clear()
|
useSessionGuardStore.getState().clear()
|
||||||
useDemoAgendaStore.getState().reset()
|
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.removeQueries({ queryKey: DEMO_AGENDA_QUERY_ROOT })
|
||||||
}, [queryClient])
|
}, [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 {
|
import {
|
||||||
DEMO_EMAILS,
|
DEMO_EMAILS,
|
||||||
|
DEMO_REFERENCE_DATE_UTC,
|
||||||
DEMO_USER,
|
DEMO_USER,
|
||||||
type DemoEmail,
|
type DemoEmail,
|
||||||
} from "@/components/demo/demo-mail-data"
|
} from "@/components/demo/demo-mail-data"
|
||||||
@ -37,31 +38,41 @@ function escapeHtml(text: string): string {
|
|||||||
.replace(/"/g, """)
|
.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 {
|
function demoTimeToIso(time: string): string {
|
||||||
const now = new Date()
|
const base = demoReferenceMidnightUtc()
|
||||||
const base = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
||||||
if (time.includes(":")) {
|
if (time.includes(":")) {
|
||||||
const [h, m] = time.split(":").map(Number)
|
const [h, m] = time.split(":").map(Number)
|
||||||
base.setHours(h, m ?? 0, 0, 0)
|
base.setUTCHours(h, m ?? 0, 0, 0)
|
||||||
return base.toISOString()
|
return base.toISOString()
|
||||||
}
|
}
|
||||||
if (time === "Hier") {
|
if (time === "Hier") {
|
||||||
base.setDate(base.getDate() - 1)
|
base.setUTCDate(base.getUTCDate() - 1)
|
||||||
base.setHours(14, 20, 0, 0)
|
base.setUTCHours(14, 20, 0, 0)
|
||||||
return base.toISOString()
|
return base.toISOString()
|
||||||
}
|
}
|
||||||
if (time === "Dim.") {
|
if (time === "Dim.") {
|
||||||
const day = base.getDay()
|
const day = base.getUTCDay()
|
||||||
const diff = day === 0 ? 0 : day
|
const diff = day === 0 ? 0 : day
|
||||||
base.setDate(base.getDate() - diff)
|
base.setUTCDate(base.getUTCDate() - diff)
|
||||||
base.setHours(3, 0, 0, 0)
|
base.setUTCHours(3, 0, 0, 0)
|
||||||
return base.toISOString()
|
return base.toISOString()
|
||||||
}
|
}
|
||||||
if (time.startsWith("Lun")) {
|
if (time.startsWith("Lun")) {
|
||||||
const day = base.getDay()
|
const day = base.getUTCDay()
|
||||||
const diff = day === 0 ? 6 : day - 1
|
const diff = day === 0 ? 6 : day - 1
|
||||||
base.setDate(base.getDate() - diff)
|
base.setUTCDate(base.getUTCDate() - diff)
|
||||||
base.setHours(11, 0, 0, 0)
|
base.setUTCHours(11, 0, 0, 0)
|
||||||
return base.toISOString()
|
return base.toISOString()
|
||||||
}
|
}
|
||||||
const weekdayOffsets: Record<string, number> = {
|
const weekdayOffsets: Record<string, number> = {
|
||||||
@ -73,14 +84,14 @@ function demoTimeToIso(time: string): string {
|
|||||||
}
|
}
|
||||||
for (const [prefix, weekday] of Object.entries(weekdayOffsets)) {
|
for (const [prefix, weekday] of Object.entries(weekdayOffsets)) {
|
||||||
if (time.startsWith(prefix)) {
|
if (time.startsWith(prefix)) {
|
||||||
const day = base.getDay()
|
const day = base.getUTCDay()
|
||||||
const diff = day >= weekday ? day - weekday : day + (7 - weekday)
|
const diff = day >= weekday ? day - weekday : day + (7 - weekday)
|
||||||
base.setDate(base.getDate() - diff)
|
base.setUTCDate(base.getUTCDate() - diff)
|
||||||
base.setHours(10, 30, 0, 0)
|
base.setUTCHours(10, 30, 0, 0)
|
||||||
return base.toISOString()
|
return base.toISOString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
base.setHours(12, 0, 0, 0)
|
base.setUTCHours(12, 0, 0, 0)
|
||||||
return base.toISOString()
|
return base.toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,24 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { MailThemeMode } from "@/lib/mail-settings/types"
|
import type { MailThemeMode } from "@/lib/mail-settings/types"
|
||||||
import { useDemoThemeStore } from "@/lib/demo/demo-theme-store"
|
import { useClientThemeStore } from "@/lib/stores/client-theme-store"
|
||||||
import { useIsDemoApp } from "@/lib/demo/use-is-demo-app"
|
|
||||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-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(): {
|
export function useThemeModeControls(): {
|
||||||
themeMode: MailThemeMode
|
themeMode: MailThemeMode
|
||||||
setThemeMode: (mode: MailThemeMode) => void
|
setThemeMode: (mode: MailThemeMode) => void
|
||||||
} {
|
} {
|
||||||
const isDemo = useIsDemoApp()
|
const themeMode = useClientThemeStore((s) => s.themeMode)
|
||||||
const mailThemeMode = useMailSettingsStore((s) => s.themeMode)
|
const setThemeMode = useClientThemeStore((s) => s.setThemeMode)
|
||||||
const setMailThemeMode = useMailSettingsStore((s) => s.setThemeMode)
|
return { themeMode, 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 }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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). */
|
/** 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"
|
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"
|
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. */
|
/** Spacer below scrollable file content — xs clears bottom bar, sm+ adds card breathing room. */
|
||||||
export const DRIVE_SCROLL_END_SPACER_CLASS =
|
export const DRIVE_SCROLL_END_SPACER_CLASS =
|
||||||
"shrink-0 h-6 max-sm:h-[calc(5.5rem+env(safe-area-inset-bottom))]"
|
"shrink-0 h-6 max-sm:h-[calc(5.5rem+env(safe-area-inset-bottom))]"
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import type {
|
|||||||
InboxSortMode,
|
InboxSortMode,
|
||||||
MailBackgroundId,
|
MailBackgroundId,
|
||||||
MailDensity,
|
MailDensity,
|
||||||
MailThemeMode,
|
|
||||||
ReadingPaneMode,
|
ReadingPaneMode,
|
||||||
} from '@/lib/mail-settings/types'
|
} from '@/lib/mail-settings/types'
|
||||||
|
|
||||||
@ -25,7 +24,6 @@ export function apiSettingsToStore(settings: ApiMailSettings) {
|
|||||||
const n = settings.notifications
|
const n = settings.notifications
|
||||||
return {
|
return {
|
||||||
density: settings.density as MailDensity,
|
density: settings.density as MailDensity,
|
||||||
themeMode: settings.theme_mode as MailThemeMode,
|
|
||||||
backgroundId: settings.background_id as MailBackgroundId,
|
backgroundId: settings.background_id as MailBackgroundId,
|
||||||
inboxSort: settings.inbox_sort as InboxSortMode,
|
inboxSort: settings.inbox_sort as InboxSortMode,
|
||||||
readingPane: settings.reading_pane as ReadingPaneMode,
|
readingPane: settings.reading_pane as ReadingPaneMode,
|
||||||
@ -39,7 +37,6 @@ export function apiSettingsToStore(settings: ApiMailSettings) {
|
|||||||
|
|
||||||
export function storeSettingsToPatch(settings: {
|
export function storeSettingsToPatch(settings: {
|
||||||
density?: MailDensity
|
density?: MailDensity
|
||||||
themeMode?: MailThemeMode
|
|
||||||
backgroundId?: MailBackgroundId
|
backgroundId?: MailBackgroundId
|
||||||
inboxSort?: InboxSortMode
|
inboxSort?: InboxSortMode
|
||||||
readingPane?: ReadingPaneMode
|
readingPane?: ReadingPaneMode
|
||||||
@ -51,7 +48,6 @@ export function storeSettingsToPatch(settings: {
|
|||||||
}): Partial<ApiMailSettings> {
|
}): Partial<ApiMailSettings> {
|
||||||
const patch: Partial<ApiMailSettings> = {}
|
const patch: Partial<ApiMailSettings> = {}
|
||||||
if (settings.density !== undefined) patch.density = settings.density
|
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.backgroundId !== undefined) patch.background_id = settings.backgroundId
|
||||||
if (settings.inboxSort !== undefined) patch.inbox_sort = settings.inboxSort
|
if (settings.inboxSort !== undefined) patch.inbox_sort = settings.inboxSort
|
||||||
if (settings.readingPane !== undefined) patch.reading_pane = settings.readingPane
|
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