"use client"; import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { SectionDivider, InfoBanner } from "@/modules/settings/components/panel/controls"; import { SETTINGS_PANEL_INSET } from "@/modules/settings/components/panel/surface"; import { queryKeys } from "@/lib/shared/query-keys"; import type { GoogleOAuthConfigPublic } from "@/lib/shared/contracts/google-drive"; import type { TailscaleConfigPublic, TailscaleInstallResult, TailscaleStatusPublic, } from "@/lib/shared/contracts/tailscale"; import type { CloudflareTunnelAppExposure, CloudflareTunnelConfigPublic, CloudflareTunnelStatus, } from "@/lib/shared/contracts/cloudflare-tunnel"; import { cn } from "@/lib/utils"; import { Check, Eye, EyeOff, ExternalLink } from "@/components/icons/platform-icons"; async function fetchGoogleOAuthConfig(): Promise { const res = await fetch("/api/v1/settings/google-oauth", { cache: "no-store" }); const json = (await res.json()) as { data?: GoogleOAuthConfigPublic; error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to fetch"); return json.data!; } async function saveGoogleOAuthConfig(payload: { clientId: string; clientSecret: string; redirectUri: string }): Promise { const res = await fetch("/api/v1/settings/google-oauth", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const json = (await res.json()) as { data?: GoogleOAuthConfigPublic; error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to save"); return json.data!; } async function clearGoogleOAuthConfig(): Promise { const res = await fetch("/api/v1/settings/google-oauth", { method: "DELETE" }); if (!res.ok) throw new Error("Failed to clear"); } async function fetchTailscaleConfig(): Promise { const res = await fetch("/api/v1/settings/tailscale", { cache: "no-store" }); const json = (await res.json()) as { data?: TailscaleConfigPublic; error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to fetch"); return json.data!; } async function saveTailscaleConfig(payload: { tailnet: string; apiKey: string }): Promise { const res = await fetch("/api/v1/settings/tailscale", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const json = (await res.json()) as { data?: TailscaleConfigPublic; error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to save"); return json.data!; } async function clearTailscaleConfig(): Promise { const res = await fetch("/api/v1/settings/tailscale", { method: "DELETE" }); if (!res.ok) throw new Error("Failed to clear"); } async function fetchTailscaleStatus(): Promise { const res = await fetch("/api/v1/system/tailscale/status", { cache: "no-store" }); const json = (await res.json()) as { data?: TailscaleStatusPublic; error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to fetch"); return json.data!; } async function installTailscaleService(authKey?: string): Promise { const res = await fetch("/api/v1/system/tailscale/install", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ authKey: authKey ?? "" }), }); const json = (await res.json()) as { data?: TailscaleInstallResult; error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to install Tailscale"); return json.data!; } function InputRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) { return (
{label}
{description &&
{description}
}
{children}
); } const inputCls = "h-8 w-64 rounded-lg border border-glass-border bg-background/55 px-3 text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-primary/40 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"; function StatusPill({ tone, label }: { tone: "ok" | "warn" | "muted"; label: string }) { return ( {label} ); } function GoogleDriveConfig() { const queryClient = useQueryClient(); const { data: saved, isLoading } = useQuery({ queryKey: queryKeys.googleOAuthConfig, queryFn: fetchGoogleOAuthConfig, }); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [redirectUri, setRedirectUri] = useState(""); const [showSecret, setShowSecret] = useState(false); const [savedOk, setSavedOk] = useState(false); const originDefault = typeof window !== "undefined" ? `${window.location.origin}/api/v1/files/google-drive/callback` : "http://localhost:3000/api/v1/files/google-drive/callback"; const effectiveRedirectUri = redirectUri || saved?.redirectUri || originDefault; const saveMutation = useMutation({ mutationFn: saveGoogleOAuthConfig, onSuccess: (data) => { void queryClient.invalidateQueries({ queryKey: queryKeys.googleOAuthConfig }); void queryClient.invalidateQueries({ queryKey: queryKeys.googleDriveConnections }); setClientSecret(""); setClientId(""); setRedirectUri(""); setSavedOk(true); setTimeout(() => setSavedOk(false), 3000); void data; }, }); const clearMutation = useMutation({ mutationFn: clearGoogleOAuthConfig, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.googleOAuthConfig }); void queryClient.invalidateQueries({ queryKey: queryKeys.googleDriveConnections }); }, }); const isConfigured = Boolean(saved?.clientId && saved?.hasSecret); const isBusy = saveMutation.isPending || clearMutation.isPending || isLoading; function handleSave() { const id = clientId.trim() || saved?.clientId || ""; const secret = clientSecret.trim(); const uri = redirectUri.trim() || effectiveRedirectUri; if (!id || !secret || !uri) return; saveMutation.mutate({ clientId: id, clientSecret: secret, redirectUri: uri }); } const hasChanges = clientId.trim().length > 0 || clientSecret.trim().length > 0 || (redirectUri.trim().length > 0 && redirectUri.trim() !== saved?.redirectUri); const canSave = hasChanges && (clientSecret.trim().length > 0); return (
{/* Status banner */} {isConfigured && !saveMutation.error && ( )} {saveMutation.error && ( )} {clearMutation.error && ( )} {/* Setup guide link */}
You need a Google Cloud project with the Drive API enabled and an OAuth 2.0 Client ID.
Google Cloud Console
{/* Redirect URI (read-only hint) */}
Redirect URI
Add this exact URI to your OAuth client's Authorized redirect URIs
{effectiveRedirectUri}
{ e.preventDefault(); handleSave(); }}> {/* Custom redirect URI override */} setRedirectUri(e.target.value)} placeholder={saved?.redirectUri ?? originDefault} disabled={isBusy} className={inputCls} /> {/* Client ID */} setClientId(e.target.value)} placeholder={saved?.clientId ? `${saved.clientId.slice(0, 24)}…` : "1234567890-abc...apps.googleusercontent.com"} disabled={isBusy} className={inputCls} /> {/* Client Secret */}
setClientSecret(e.target.value)} type={showSecret ? "text" : "password"} placeholder={saved?.hasSecret ? "••••••••••••••••" : "GOCSPX-..."} disabled={isBusy} className={cn(inputCls, "pr-9")} />
{/* Actions */}
{isConfigured ? ( ) : ( )}
); } async function fetchCloudflareTunnelConfig(): Promise { const res = await fetch("/api/v1/settings/cloudflare-tunnel", { cache: "no-store" }); const json = (await res.json()) as { data?: CloudflareTunnelConfigPublic; error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to fetch"); return json.data!; } async function saveCloudflareTunnelConfigRequest(payload: { enabled: boolean; domain: string; token?: string; apiToken?: string; }): Promise { const res = await fetch("/api/v1/settings/cloudflare-tunnel", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const json = (await res.json()) as { data?: CloudflareTunnelConfigPublic; error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to save"); return json.data!; } async function fetchTunnelStatus(): Promise { const res = await fetch("/api/v1/system/cloudflare-tunnel", { cache: "no-store" }); const json = (await res.json()) as { data?: CloudflareTunnelStatus; error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to fetch status"); return json.data!; } async function activateTunnel(token?: string): Promise { const res = await fetch("/api/v1/system/cloudflare-tunnel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(token ? { token } : {}), }); const json = (await res.json()) as { data?: CloudflareTunnelStatus; error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to activate the tunnel"); return json.data!; } async function deactivateTunnel(): Promise { const res = await fetch("/api/v1/system/cloudflare-tunnel", { method: "DELETE" }); const json = (await res.json()) as { data?: CloudflareTunnelStatus; error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to stop the tunnel"); return json.data!; } async function fetchTunnelExposures(): Promise { const res = await fetch("/api/v1/settings/cloudflare-tunnel/exposures", { cache: "no-store" }); const json = (await res.json()) as { data?: CloudflareTunnelAppExposure[]; error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to fetch"); return json.data!; } async function saveTunnelExposure(payload: { appId: string; exposed: boolean; subdomain?: string; }): Promise { const res = await fetch("/api/v1/settings/cloudflare-tunnel/exposures", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const json = (await res.json()) as { data?: CloudflareTunnelAppExposure; error?: string }; if (!res.ok) throw new Error(json.error ?? "Failed to save"); return json.data!; } function TailscaleConfig() { const queryClient = useQueryClient(); const { data: saved, isLoading } = useQuery({ queryKey: queryKeys.tailscaleConfig, queryFn: fetchTailscaleConfig, }); const { data: status, isLoading: isStatusLoading } = useQuery({ queryKey: queryKeys.tailscaleStatus, queryFn: fetchTailscaleStatus, refetchInterval: 10_000, }); const [tailnet, setTailnet] = useState(""); const [apiKey, setApiKey] = useState(""); const [showApiKey, setShowApiKey] = useState(false); const [savedOk, setSavedOk] = useState(false); const saveMutation = useMutation({ mutationFn: saveTailscaleConfig, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.tailscaleConfig }); setTailnet(""); setApiKey(""); setSavedOk(true); setTimeout(() => setSavedOk(false), 3000); }, }); const clearMutation = useMutation({ mutationFn: clearTailscaleConfig, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.tailscaleConfig }); }, }); const activateMutation = useMutation({ mutationFn: async () => { const hasNewCredentials = effectiveTailnet.length > 0 && apiKey.trim().length > 0; if (hasNewCredentials) { await saveTailscaleConfig({ tailnet: effectiveTailnet, apiKey: apiKey.trim(), }); } await installTailscaleService(apiKey.trim() || undefined); }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.tailscaleConfig }); void queryClient.invalidateQueries({ queryKey: queryKeys.tailscaleStatus }); setTailnet(""); setApiKey(""); setSavedOk(true); setTimeout(() => setSavedOk(false), 3000); }, }); const isConfigured = Boolean(saved?.tailnet && saved?.hasApiKey); const isBusy = saveMutation.isPending || clearMutation.isPending || activateMutation.isPending || isLoading; const effectiveTailnet = tailnet.trim() || saved?.tailnet || ""; const canSave = effectiveTailnet.length > 0 && apiKey.trim().length > 0; const canActivate = (isConfigured || canSave) && status?.issue !== "missing_tun"; const serviceLabel = isStatusLoading ? "Checking" : status?.installed ? status.running ? "Running" : "Installed" : "Not installed"; const connectionLabel = status?.connected ? "Connected" : "Disconnected"; function handleSave() { if (!canSave) return; saveMutation.mutate({ tailnet: effectiveTailnet, apiKey: apiKey.trim(), }); } return (
{isConfigured && !saveMutation.error && ( )} {saveMutation.error && ( )} {clearMutation.error && ( )} {activateMutation.error && ( )} {status?.issue === "missing_tun" && ( )} {status?.error && status.issue !== "missing_tun" && ( )}
{/* eslint-disable-next-line @next/next/no-img-element */}
Tailscale
{status?.connected ? `Connected${status.hostname ? ` as ${status.hostname}` : ""}. The fields below are only needed to install or re-register.` : "Install the official Linux client and register this server with an auth key."}
Service
{serviceLabel}
Connection
{connectionLabel}
TUN device
{status?.tunAvailable ? "Available" : "Missing"}
Machine
{status?.hostname ?? "--"}
Tailscale IP
{status?.tailscaleIps[0] ?? "--"}
{status?.connected ? "Already registered — Tailscale keeps its own state on this machine, so nothing here has to be filled in again." : "Activate uses the official Linux install script, then runs tailscale up with your auth key."}
Tailscale keys
{ e.preventDefault(); handleSave(); }}> setTailnet(e.target.value)} placeholder={saved?.tailnet || "example.com"} disabled={isBusy} className={inputCls} />
setApiKey(e.target.value)} type={showApiKey ? "text" : "password"} placeholder={saved?.hasApiKey ? "••••••••••••••••" : "tskey-auth-..."} disabled={isBusy} className={cn(inputCls, "pr-9")} />
{isConfigured ? ( ) : ( )}
); } /** * A secret that is already stored should not look like an empty box waiting for * input: it reads as unsaved and invites re-typing something that is already * right. Locked once saved, with Edit to deliberately replace it. */ function SecretField({ id, label, hint, placeholder, stored, busy, onSave, }: { id: string; label: string; hint: React.ReactNode; placeholder: string; stored: boolean; busy: boolean; onSave: (value: string) => Promise; }) { const [editing, setEditing] = useState(false); const [value, setValue] = useState(""); const [reveal, setReveal] = useState(false); const locked = stored && !editing; async function submit() { const next = value.trim(); if (!next) return; await onSave(next); // Only lock once it actually saved; a rejected token stays editable. setValue(""); setReveal(false); setEditing(false); } return (
setValue(event.target.value)} placeholder={locked ? "••••••••" : placeholder} className="h-8 flex-1 rounded-lg border border-glass-border bg-background/55 px-2.5 text-xs text-foreground disabled:cursor-not-allowed disabled:opacity-60" /> {!locked && ( )} {locked ? ( ) : ( <> {stored && ( )} )}

{hint}

); } function TunnelExposureRow({ exposure, domain, disabled, onSave, }: { exposure: CloudflareTunnelAppExposure; domain: string; disabled: boolean; onSave: (input: { appId: string; exposed: boolean; subdomain: string }) => void; }) { const [subdomain, setSubdomain] = useState(exposure.subdomain); return (
onSave({ appId: exposure.appId, exposed: event.target.checked, subdomain: subdomain.trim(), }) } className="size-3.5 shrink-0 accent-primary disabled:opacity-40" />
{exposure.name}
{exposure.port === null ? "No web UI port" : `Port ${exposure.port}`}
setSubdomain(event.target.value)} onBlur={() => { const next = subdomain.trim(); if (!exposure.exposed || next === exposure.subdomain) return; onSave({ appId: exposure.appId, exposed: true, subdomain: next }); }} className="h-7 w-28 rounded-lg border border-glass-border bg-background/55 px-2 text-right text-[11px] text-foreground disabled:opacity-40" /> .{domain || "your-domain.com"}
); } function CloudflareTunnelConfig() { const queryClient = useQueryClient(); const { data: saved, isLoading } = useQuery({ queryKey: queryKeys.cloudflareTunnelConfig, queryFn: fetchCloudflareTunnelConfig, }); const enabled = Boolean(saved?.enabled); // Only poll the connector once the operator has switched the feature on. const { data: status, isLoading: isStatusLoading } = useQuery({ queryKey: queryKeys.cloudflareTunnelStatus, queryFn: fetchTunnelStatus, enabled, refetchInterval: enabled ? 10_000 : false, }); const isActive = Boolean(status?.running); // The app list only makes sense once the tunnel actually carries traffic. const { data: exposures } = useQuery({ queryKey: queryKeys.cloudflareTunnelExposures, queryFn: fetchTunnelExposures, enabled: enabled && isActive, }); const [domain, setDomain] = useState(""); const [savedOk, setSavedOk] = useState(false); const effectiveDomain = domain.trim() || saved?.domain || ""; function invalidateConfig() { void queryClient.invalidateQueries({ queryKey: queryKeys.cloudflareTunnelConfig }); void queryClient.invalidateQueries({ queryKey: queryKeys.cloudflareTunnelStatus }); } const configMutation = useMutation({ mutationFn: saveCloudflareTunnelConfigRequest, onSuccess: () => { invalidateConfig(); setSavedOk(true); setTimeout(() => setSavedOk(false), 3000); }, }); const activateMutation = useMutation({ mutationFn: () => activateTunnel(), onSuccess: () => { invalidateConfig(); void queryClient.invalidateQueries({ queryKey: queryKeys.cloudflareTunnelExposures }); }, }); const deactivateMutation = useMutation({ mutationFn: deactivateTunnel, onSuccess: invalidateConfig, }); const exposureMutation = useMutation({ mutationFn: saveTunnelExposure, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.cloudflareTunnelExposures }); // The app's link changes with its exposure, so the grid has to catch up. void queryClient.invalidateQueries({ queryKey: queryKeys.installedApps }); void queryClient.invalidateQueries({ queryKey: queryKeys.storeCatalog }); }, }); const isBusy = isLoading || configMutation.isPending || activateMutation.isPending || deactivateMutation.isPending || exposureMutation.isPending; const hasToken = Boolean(saved?.hasToken); const canActivate = enabled && hasToken && effectiveDomain.length > 0; const connectorLabel = !enabled ? "Disabled" : isStatusLoading ? "Checking" : isActive ? "Running" : status?.installed ? (status.state ?? "Stopped") : "Not activated"; const others = enabled ? (status?.otherConnectors ?? []).filter((connector) => connector.running) : []; const sameTunnel = others.filter((connector) => connector.sameTunnel === true); const maybeSameTunnel = others.filter((connector) => connector.sameTunnel === null); const listNames = (connectors: typeof others) => connectors.map((connector) => connector.name).join(", "); const activationError = (configMutation.error as Error | null)?.message ?? (activateMutation.error as Error | null)?.message ?? (deactivateMutation.error as Error | null)?.message ?? null; return (
{activationError && } {exposureMutation.error && ( )} {enabled && !isActive && status?.error && ( )} {sameTunnel.length > 0 && ( 1 ? "those containers" : "that container"} or Homeio's connector.`} variant="warning" /> )} {maybeSameTunnel.length > 0 && ( )} {savedOk && !activationError && ( )}
Cloudflare Tunnel
Publish apps on your own domain. Homeio fills in each app's link so Open goes to the public address instead of this server's.
{enabled && ( <>
setDomain(event.target.value)} onBlur={() => { const next = domain.trim(); if (!next || next === saved?.domain) return; configMutation.mutate({ enabled: true, domain: next }); }} placeholder="example.com" className="h-8 rounded-lg border border-glass-border bg-background/55 px-2.5 text-xs text-foreground" />
From Cloudflare Zero Trust · Networks · Tunnels · Add a replica. Paste the command it shows — Homeio takes the token out of it. } onSave={(value) => configMutation.mutateAsync({ enabled: true, domain: effectiveDomain, token: value, }) } /> With it, Homeio creates and removes each public hostname itself. Without it, add the routes in your Cloudflare dashboard. } onSave={(value) => configMutation.mutateAsync({ enabled, domain: effectiveDomain, apiToken: value, }) } />
Connector: {connectorLabel} {status?.installed ? ( {status.containerName} ) : null}
{isActive && ( )}
)} {enabled && isActive && (
Apps to expose
{exposures && exposures.length > 0 ? (
{exposures.map((exposure) => ( exposureMutation.mutate(input)} /> ))}
) : (
No installed apps to expose yet.
)}

{saved?.hasApiToken ? "Homeio creates and removes the matching public hostname and DNS record for you." : "Homeio records the mapping and updates each app's link. Add an API token above to have it create the public hostname routes too."}

)}
); } export function IntegrationsSection() { return (
); }