From 72108b1d3f431f94e83a258c12657135ca287878 Mon Sep 17 00:00:00 2001 From: bagas Date: Mon, 5 Jan 2026 01:02:12 +0700 Subject: [PATCH] feat: implement slug changing --- app/api/session/[...node]/route.ts | 28 ++++ app/dashboard/dashboard-client.tsx | 227 ++++++++++++++++++++++++++--- 2 files changed, 233 insertions(+), 22 deletions(-) create mode 100644 app/api/session/[...node]/route.ts diff --git a/app/api/session/[...node]/route.ts b/app/api/session/[...node]/route.ts new file mode 100644 index 0000000..d7e7c45 --- /dev/null +++ b/app/api/session/[...node]/route.ts @@ -0,0 +1,28 @@ +import { auth } from "@/lib/auth"; +import { headers } from "next/headers" +import { NextRequest } from "next/server"; + +export async function PATCH(req: NextRequest, context: { params: Promise<{ node: string[] }> }) { + const { node } = await context.params; + const requestHeaders = await headers(); + const result = await auth.api.getToken({ headers: requestHeaders }).catch(() => null); + if (!result || typeof result !== "object" || !("token" in result)) { + return new Response("Unauthorized", { status: 401 }); + } + + const { token } = result as { token: string }; + if (!token) { + return new Response("Unauthorized", { status: 401 }); + } + + const data = await fetch(`${process.env.API_URL}/api/session/${node.join("/")}`, { + method: "PATCH", + headers: { + authorization: `Bearer ${token}`, + }, + cache: "no-store", + body: await req.text(), + }) + + return data; +} \ No newline at end of file diff --git a/app/dashboard/dashboard-client.tsx b/app/dashboard/dashboard-client.tsx index bb6755e..ab085d0 100644 --- a/app/dashboard/dashboard-client.tsx +++ b/app/dashboard/dashboard-client.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useState, type FormEvent } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import TunnelConfig, { type TunnelConfig as TunnelConfigType, type Server } from "@/components/tunnel-config" @@ -35,10 +35,9 @@ const toActiveConnection = (session: ApiSession): ActiveConnection => { status: session.active ? "connected" : "error", protocol: (session.forwarding_type || "http").toLowerCase(), serverLabel: session.node || "Unknown node", + node: session.node, remote: session.slug ? `${session.slug}.${session.node}` : session.node || "—", startedAgo, - latencyMs: null, - dataInOut: undefined, } } @@ -72,12 +71,11 @@ type ActiveConnection = { status: ActiveConnectionStatus protocol: string serverLabel: string + node?: string remote: string localPort?: number serverPort?: number startedAgo?: string - latencyMs?: number | null - dataInOut?: string } export default function DashboardClient({ initialActiveConnections }: DashboardClientProps) { @@ -90,6 +88,15 @@ export default function DashboardClient({ initialActiveConnections }: DashboardC ) const { data: cachedSession } = authClient.useSession() const [session, setSession] = useState(cachedSession ?? null) + const [openActionId, setOpenActionId] = useState(null) + const [slugModal, setSlugModal] = useState<{ + connectionId: string + currentSlug: string + newSlug: string + node: string + } | null>(null) + const [slugError, setSlugError] = useState(null) + const [slugSaving, setSlugSaving] = useState(false) useEffect(() => { setActiveConnections(initialActiveConnections.map(toActiveConnection)) @@ -104,10 +111,161 @@ export default function DashboardClient({ initialActiveConnections }: DashboardC const stopConnection = (id: string) => { setActiveConnections((prev) => prev.filter((conn) => conn.id !== id)) setStatusMessage("Connection stopped") + setOpenActionId((current) => (current === id ? null : current)) } + const openChangeSlugModal = (connection: ActiveConnection) => { + setSlugModal({ + connectionId: connection.id, + currentSlug: connection.name, + newSlug: connection.name, + node: connection.node || connection.serverLabel, + }) + setSlugError(null) + setOpenActionId(null) + } + + const closeSlugModal = () => setSlugModal(null) + + const validateSlug = (value: string): string | null => { + const trimmed = value.trim().toLowerCase() + if (trimmed.length < 3 || trimmed.length > 20) return "Slug must be 3-20 characters." + if (!/^[a-z0-9-]+$/.test(trimmed)) return "Only lowercase letters, numbers, and hyphens are allowed." + if (trimmed.startsWith("-") || trimmed.endsWith("-")) return "No leading or trailing hyphens." + return null + } + + const submitSlugChange = async (event: FormEvent) => { + event.preventDefault() + if (!slugModal) return + + const trimmedSlug = slugModal.newSlug.trim().toLowerCase() + const validationError = validateSlug(trimmedSlug) + setSlugError(validationError) + if (validationError) return + + setSlugSaving(true) + setStatusMessage(null) + + try { + const response = await fetch(`/api/session/${slugModal.node}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ old: slugModal.currentSlug, new: trimmedSlug }), + }) + + if (!response.ok) { + const message = await response.text() + setSlugError(message || "Failed to update slug.") + return + } + + setActiveConnections((prev) => + prev.map((conn) => + conn.id === slugModal.connectionId + ? { + ...conn, + name: trimmedSlug, + remote: conn.node ? `${trimmedSlug}.${conn.node}` : trimmedSlug, + } + : conn, + ), + ) + + setStatusMessage("Slug updated") + setSlugModal(null) + } catch (error) { + console.error("Failed to update slug", error) + setSlugError("Failed to update slug.") + } finally { + setSlugSaving(false) + } + } + + useEffect(() => { + if (slugModal) { + const previousOverflow = document.body.style.overflow + document.body.style.overflow = "hidden" + return () => { + document.body.style.overflow = previousOverflow + } + } + }, [slugModal]) + + useEffect(() => { + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpenActionId(null) + setSlugModal(null) + } + } + + window.addEventListener("keydown", closeOnEscape) + return () => window.removeEventListener("keydown", closeOnEscape) + }, []) + + const slugModalContent = !slugModal + ? null + : ( +
+
+
+
+

Change slug

+

Update the identifier for this tunnel.

+
+ +
+ +
+ + +
+ + +
+
+
+
+ ) + return ( -
+ <> +

Active Forwarding

@@ -178,27 +336,50 @@ export default function DashboardClient({ initialActiveConnections }: DashboardC

{(connection.protocol || "http").toUpperCase()} · {connection.serverLabel}

-

{connection.remote || "—"}

+ {(() => { + const isTcp = connection.protocol === "tcp" + const isHttp = connection.protocol === "http" || connection.protocol === "https" + const httpRemote = connection.remote ? `https://${connection.remote}` : "—" + const tcpRemote = + connection.node && connection.name + ? `tcp://${connection.node}:${connection.name}` + : connection.remote || "—" + + const displayRemote = isTcp ? tcpRemote : isHttp ? httpRemote : connection.remote || "—" + + return

{displayRemote}

+ })()}

{metaText}

-
-
-

Latency

-

- {connection.latencyMs != null ? `${connection.latencyMs}ms` : "—"} -

-
-
-

Data

-

{connection.dataInOut || "—"}

-
+
+ + {openActionId === connection.id && ( +
+ + {connection.protocol !== "tcp" && ( + + )} +
+ )}
) @@ -228,6 +409,8 @@ export default function DashboardClient({ initialActiveConnections }: DashboardC />
-
+
+ {slugModalContent} + ) }