226 lines
6.7 KiB
TypeScript
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,
|
|
}
|