frontend: runtime backend URL (LAN), voice dictation, version + update UI

Resolve the backend URL from the current host at runtime (lib/backend-url.ts)
so one build works on localhost and any clinic LAN IP without a rebuild; used
by the API client, auth client, and socket. Wire the AI-chat mic to the Web
Speech API with graceful fallback. Add a Settings "About & updates" panel
(current/latest version, update command, shareable network address) and an
optional dismissible update banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-26 21:34:30 +03:00
parent 2454bb4c2b
commit 403e0e38e9
12 changed files with 425 additions and 13 deletions
+5 -2
View File
@@ -1,8 +1,11 @@
// Thin fetch wrapper for the temetro backend's non-auth API (patients, …).
// Auth itself goes through the Better Auth client (lib/auth-client.ts).
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
import { resolveBackendUrl } from "@/lib/backend-url";
// Derived from the current host in the browser (see lib/backend-url.ts), so the
// app works over localhost and the clinic LAN without a per-deployment rebuild.
export const API_BASE_URL = resolveBackendUrl();
export class ApiError extends Error {
constructor(
+5 -2
View File
@@ -5,12 +5,15 @@ import {
import { createAuthClient } from "better-auth/react";
import { ac, roles } from "@/lib/access";
import { resolveBackendUrl } from "@/lib/backend-url";
// The backend (Express + Better Auth) is a separate origin. The client appends
// `/api/auth` to this base URL and always sends credentials so the session
// cookie set by the backend is included on every request.
// cookie set by the backend is included on every request. The base URL is
// derived from the current host (see lib/backend-url.ts) so login works over
// localhost and the clinic LAN alike.
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000",
baseURL: resolveBackendUrl(),
// usernameClient enables signIn.username(...) so admin-provisioned staff can
// log in with a username; organizationClient powers clinics + RBAC.
plugins: [usernameClient(), organizationClient({ ac, roles })],
+22
View File
@@ -0,0 +1,22 @@
// Resolves the backend's base URL at runtime instead of baking it in at build.
//
// temetro ships as a prebuilt image and is reached from many hosts — the
// server's own machine (localhost) and other departments over the LAN (the
// host's IP, e.g. http://192.168.1.20:3000). A URL baked at build time can't
// serve all of them, so in the browser we derive the backend URL from the page
// the user actually loaded (same hostname, the backend's port). Set
// NEXT_PUBLIC_API_URL to override for custom/reverse-proxied deployments.
const ENV_URL = process.env.NEXT_PUBLIC_API_URL?.trim();
// Backend port; override only if you publish the API on a non-default port.
const API_PORT = process.env.NEXT_PUBLIC_API_PORT?.trim() || "4000";
/** The backend origin to call from the current context. */
export function resolveBackendUrl(): string {
if (ENV_URL) return ENV_URL;
if (typeof window !== "undefined") {
return `${window.location.protocol}//${window.location.hostname}:${API_PORT}`;
}
// Server-side fallback (SSR/build); real browser calls use the branch above.
return "http://localhost:4000";
}
+26 -1
View File
@@ -1032,6 +1032,8 @@
"attachFiles": "Attach files",
"removeFile": "Remove {{name}}",
"dictate": "Dictate",
"dictateStop": "Stop dictation",
"dictateUnsupported": "Voice dictation isn't supported in this browser",
"send": "Send",
"stop": "Stop",
"model": "Model",
@@ -1485,7 +1487,30 @@
"signing": "Signing",
"careTeam": "Care team",
"integrations": "Integrations",
"developers": "Developers"
"developers": "Developers",
"version": "About & updates"
},
"version": {
"title": "Version",
"description": "The temetro version running on this server, and whether a newer release is available.",
"current": "Current version",
"latest": "Latest release",
"checking": "Checking for updates…",
"upToDate": "Up to date",
"updateAvailable": "Update available",
"offline": "Couldn't reach the update server",
"updateTitle": "How to update",
"updateDescription": "temetro is self-hosted with Docker. To update, pull the new images and restart on the server machine.",
"updateCommandLabel": "Update command",
"viewRelease": "View release notes",
"networkTitle": "Network access",
"networkDescription": "Let other departments open temetro from their own computers on the hospital network using this address.",
"networkUrlLabel": "Network address",
"networkLocalHint": "You're viewing temetro locally. To share it, open the app on the server machine using its network IP address (e.g. from ipconfig / ifconfig), then share that URL.",
"networkFirewallHint": "The server's firewall must allow the port for other devices to connect.",
"banner": "A new version of temetro ({{version}}) is available.",
"bannerUpdate": "See how to update",
"bannerDismiss": "Later"
},
"integrations": {
"loading": "Loading integrations…",
+3 -2
View File
@@ -1,6 +1,6 @@
import { io, type Socket } from "socket.io-client";
import { API_BASE_URL } from "@/lib/api-client";
import { resolveBackendUrl } from "@/lib/backend-url";
// A single shared Socket.io connection to the backend, authenticated by the
// Better Auth session cookie (withCredentials). Used by messaging and
@@ -9,7 +9,8 @@ let socket: Socket | null = null;
export function getSocket(): Socket {
if (!socket) {
socket = io(API_BASE_URL, {
// Resolved lazily in the browser so it targets the host actually in use.
socket = io(resolveBackendUrl(), {
withCredentials: true,
transports: ["websocket", "polling"],
});
+23
View File
@@ -0,0 +1,23 @@
// Client helpers for the backend's version + network endpoints (both public).
import { apiFetch } from "@/lib/api-client";
export type VersionInfo = {
current: string;
latest: string | null;
updateAvailable: boolean;
releaseUrl: string | null;
};
export type NetworkInfo = {
port: number;
addresses: string[];
urls: string[];
};
export function getVersionInfo(): Promise<VersionInfo> {
return apiFetch<VersionInfo>("/api/version");
}
export function getNetworkInfo(): Promise<NetworkInfo> {
return apiFetch<NetworkInfo>("/api/network");
}