ultisuite-client/lib/runtime-config/index.ts
R3D347HR4Y d6d18f911b
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
Lots of stuff and mobile app
2026-06-17 00:13:28 +02:00

226 lines
6.7 KiB
TypeScript

"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,
}