v0.1.2 — UX fixes: dark dropdowns, @ for root in DNS table, copy fallback, geo settings UI

This commit is contained in:
Hendrik 2026-05-01 18:31:21 +02:00
parent c06a16d86e
commit 26725f9f15
6 changed files with 249 additions and 44 deletions

View file

@ -1,13 +1,14 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Copy, CheckCircle2, AlertTriangle, ArrowRight } from "lucide-react";
import { Loader2, Copy, Check, CheckCircle2, AlertTriangle, ArrowRight } from "lucide-react";
import { PageHeader } from "@/components/PageHeader";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { copyToClipboard } from "@/lib/clipboard";
type Group = { id: number; name: string; target_url: string };
@ -132,11 +133,11 @@ export default function NewDomainPage() {
required
value={groupId}
onChange={(e) => setGroupId(e.target.value ? Number(e.target.value) : "")}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex h-9 w-full rounded-md border border-input bg-zinc-950 px-3 py-1 text-sm text-zinc-100 shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value=""> wählen </option>
<option value="" className="bg-zinc-900 text-zinc-100"> wählen </option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name} {g.target_url}</option>
<option key={g.id} value={g.id} className="bg-zinc-900 text-zinc-100">{g.name} {g.target_url}</option>
))}
</select>
</div>
@ -149,10 +150,10 @@ export default function NewDomainPage() {
id="code"
value={redirectCode}
onChange={(e) => setRedirectCode(Number(e.target.value) as 301 | 302)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
className="flex h-9 w-full rounded-md border border-input bg-zinc-950 px-3 py-1 text-sm text-zinc-100"
>
<option value={301}>301 Permanent</option>
<option value={302}>302 Temporär</option>
<option value={301} className="bg-zinc-900 text-zinc-100">301 Permanent</option>
<option value={302} className="bg-zinc-900 text-zinc-100">302 Temporär</option>
</select>
</div>
<div className="flex items-end gap-4">
@ -266,41 +267,61 @@ function StepIndicator({ step }: { step: 1 | 2 | 3 }) {
function DnsRecordsTable({ domain, ipv4, ipv6, includeWww }: { domain: string; ipv4?: string; ipv6?: string; includeWww: boolean }) {
const records = [
{ type: "A", name: domain, value: ipv4 || "<server-IP>" },
...(ipv6 ? [{ type: "AAAA", name: domain, value: ipv6 }] : []),
...(includeWww ? [{ type: "A", name: `www.${domain}`, value: ipv4 || "<server-IP>" }] : []),
{ type: "A", name: "@", note: `(Root: ${domain})`, value: ipv4 || "<server-IP>" },
...(ipv6 ? [{ type: "AAAA", name: "@", note: `(Root: ${domain})`, value: ipv6 }] : []),
...(includeWww ? [{ type: "A", name: "www", note: `(www.${domain})`, value: ipv4 || "<server-IP>" }] : []),
];
const [copied, setCopied] = useState<number | null>(null);
async function copy(idx: number, val: string) {
const ok = await copyToClipboard(val);
if (ok) {
setCopied(idx);
setTimeout(() => setCopied(null), 1500);
} else {
alert("Kopieren fehlgeschlagen — bitte manuell markieren und kopieren.");
}
}
return (
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-zinc-900/40 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-4 py-2 text-left font-medium">Typ</th>
<th className="px-4 py-2 text-left font-medium">Name</th>
<th className="px-4 py-2 text-left font-medium">Wert</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/70">
{records.map((r, i) => (
<tr key={i}>
<td className="px-4 py-2"><Badge variant="zinc">{r.type}</Badge></td>
<td className="px-4 py-2 font-mono text-xs">{r.name}</td>
<td className="px-4 py-2 font-mono text-xs">{r.value}</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => navigator.clipboard?.writeText(r.value)}
className="rounded p-1 text-zinc-500 hover:text-zinc-200"
title="Wert kopieren"
>
<Copy className="h-3 w-3" />
</button>
</td>
<div className="space-y-2">
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-zinc-900/40 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-4 py-2 text-left font-medium">Typ</th>
<th className="px-4 py-2 text-left font-medium">Name</th>
<th className="px-4 py-2 text-left font-medium">Wert</th>
<th className="px-4 py-2"></th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-zinc-800/70">
{records.map((r, i) => (
<tr key={i}>
<td className="px-4 py-2"><Badge variant="zinc">{r.type}</Badge></td>
<td className="px-4 py-2 font-mono text-xs">
<span className="text-zinc-100">{r.name}</span>
<span className="ml-2 text-[10px] text-muted-foreground">{r.note}</span>
</td>
<td className="px-4 py-2 font-mono text-xs">{r.value}</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => copy(i, r.value)}
className="rounded p-1 text-zinc-500 hover:text-zinc-200"
title="Wert kopieren"
type="button"
>
{copied === i ? <Check className="h-3 w-3 text-green-400" /> : <Copy className="h-3 w-3" />}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-[11px] text-muted-foreground">
Hinweis: <code className="font-mono text-zinc-300">@</code> steht für die Root-Domain. Manche DNS-Provider erwarten stattdessen <code className="font-mono text-zinc-300">{domain}</code> oder ein leeres Feld.
</p>
</div>
);
}

View file

@ -89,9 +89,9 @@ export default function GroupsPage() {
</div>
<div className="space-y-2">
<Label htmlFor="code">Status-Code</Label>
<select id="code" value={code} onChange={(e) => setCode(Number(e.target.value) as 301 | 302)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm">
<option value={301}>301 Permanent</option>
<option value={302}>302 Temporär</option>
<select id="code" value={code} onChange={(e) => setCode(Number(e.target.value) as 301 | 302)} className="flex h-9 w-full rounded-md border border-input bg-zinc-950 px-3 py-1 text-sm text-zinc-100">
<option value={301} className="bg-zinc-900 text-zinc-100">301 Permanent</option>
<option value={302} className="bg-zinc-900 text-zinc-100">302 Temporär</option>
</select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2, RefreshCcw, ArrowUpCircle } from "lucide-react";
import { Loader2, RefreshCcw, ArrowUpCircle, Globe2, CheckCircle2, Trash2 } from "lucide-react";
import { PageHeader } from "@/components/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@ -27,21 +27,56 @@ type UpdateStatus = {
export default function SettingsPage() {
const [settings, setSettings] = useState<Settings | null>(null);
const [status, setStatus] = useState<UpdateStatus | null>(null);
const [geo, setGeo] = useState<{ available: boolean; path: string } | null>(null);
const [licenseKey, setLicenseKey] = useState("");
const [installingGeo, setInstallingGeo] = useState(false);
const [geoMsg, setGeoMsg] = useState("");
const [saving, setSaving] = useState(false);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [msg, setMsg] = useState("");
async function load() {
const [s, u] = await Promise.all([
const [s, u, g] = await Promise.all([
fetch("/api/settings").then((r) => r.json()),
fetch("/api/update/check").then((r) => r.json()),
fetch("/api/settings/geo").then((r) => r.json()),
]);
setSettings(s);
setStatus(u);
setGeo(g);
}
useEffect(() => { load(); }, []);
async function installGeo() {
if (!licenseKey.trim()) return;
setInstallingGeo(true);
setGeoMsg("");
try {
const r = await fetch("/api/settings/geo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ license_key: licenseKey.trim() }),
});
const d = await r.json();
if (r.ok) {
setGeoMsg("GeoLite2-DB installiert.");
setLicenseKey("");
load();
} else {
setGeoMsg(`Fehler: ${d.error || "Download fehlgeschlagen"}`);
}
} finally {
setInstallingGeo(false);
}
}
async function removeGeo() {
if (!confirm("GeoIP-DB entfernen? Geo-Lookup wird deaktiviert.")) return;
await fetch("/api/settings/geo", { method: "DELETE" });
load();
}
async function save(patch: Partial<Settings>) {
setSaving(true);
await fetch("/api/settings", {
@ -74,7 +109,7 @@ export default function SettingsPage() {
}
}
if (!settings || !status) {
if (!settings || !status || !geo) {
return <div className="flex justify-center p-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
}
@ -151,6 +186,51 @@ export default function SettingsPage() {
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe2 className="h-4 w-4" />
GeoIP-Tracking
{geo.available
? <Badge variant="green"><CheckCircle2 className="mr-1 h-3 w-3" />aktiv</Badge>
: <Badge variant="zinc">deaktiviert</Badge>}
</CardTitle>
<CardDescription>
MaxMind GeoLite2-Country für Land-Auflösung pro Hit. Lizenz-Key kostenlos unter <a href="https://www.maxmind.com/en/geolite2/signup" target="_blank" rel="noreferrer" className="text-cyan-400 hover:underline">maxmind.com</a> generieren.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{geo.available ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">DB-Pfad: <span className="font-mono">{geo.path}</span></p>
<Button onClick={removeGeo} variant="destructive" size="sm">
<Trash2 className="mr-2 h-3 w-3" />Entfernen
</Button>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="licenseKey">MaxMind License-Key</Label>
<div className="flex gap-2">
<Input
id="licenseKey"
type="password"
placeholder="xxxxxxxxxxxxxxxx"
value={licenseKey}
onChange={(e) => setLicenseKey(e.target.value)}
disabled={installingGeo}
/>
<Button onClick={installGeo} disabled={installingGeo || !licenseKey.trim()}>
{installingGeo ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : null}
Installieren
</Button>
</div>
<p className="text-[11px] text-muted-foreground">Lädt GeoLite2-Country.mmdb herunter und aktiviert Geo-Lookup.</p>
</div>
)}
{geoMsg && <p className="text-xs text-muted-foreground">{geoMsg}</p>}
</CardContent>
</Card>
</div>
</div>
);

View file

@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { spawn } from "child_process";
import path from "path";
import fs from "fs/promises";
import os from "os";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { resetGeoReader, geoStatus } from "@/lib/geo";
const DATA_DIR = process.env.NEXREDIRECT_DATA_DIR || path.join(process.cwd(), "data");
const MMDB_PATH = process.env.NEXREDIRECT_GEOIP_PATH || path.join(DATA_DIR, "GeoLite2-Country.mmdb");
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
return NextResponse.json(geoStatus());
}
const schema = z.object({
license_key: z.string().min(10),
});
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const body = await req.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "invalid" }, { status: 400 });
const url = `https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${encodeURIComponent(parsed.data.license_key)}&suffix=tar.gz`;
try {
const res = await fetch(url);
if (!res.ok) return NextResponse.json({ error: "download_failed", status: res.status }, { status: 502 });
const buf = Buffer.from(await res.arrayBuffer());
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "geo-"));
const tarPath = path.join(tmpDir, "geo.tgz");
await fs.writeFile(tarPath, buf);
await new Promise<void>((resolve, reject) => {
const p = spawn("tar", ["-xzf", tarPath, "-C", tmpDir]);
p.on("error", reject);
p.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`tar exit ${code}`))));
});
const entries = await fs.readdir(tmpDir, { recursive: true }) as string[];
const mmdb = entries.find((e) => e.endsWith("GeoLite2-Country.mmdb"));
if (!mmdb) return NextResponse.json({ error: "mmdb_not_found_in_archive" }, { status: 500 });
await fs.mkdir(path.dirname(MMDB_PATH), { recursive: true });
await fs.copyFile(path.join(tmpDir, mmdb), MMDB_PATH);
await fs.rm(tmpDir, { recursive: true, force: true });
resetGeoReader();
return NextResponse.json({ ok: true, path: MMDB_PATH });
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 });
}
}
export async function DELETE() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
await fs.unlink(MMDB_PATH).catch(() => {});
resetGeoReader();
return NextResponse.json({ ok: true });
}

26
lib/clipboard.ts Normal file
View file

@ -0,0 +1,26 @@
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fall through
}
try {
const el = document.createElement("textarea");
el.value = text;
el.style.position = "fixed";
el.style.left = "-9999px";
el.style.top = "0";
el.setAttribute("readonly", "");
document.body.appendChild(el);
el.select();
el.setSelectionRange(0, text.length);
const ok = document.execCommand("copy");
document.body.removeChild(el);
return ok;
} catch {
return false;
}
}

View file

@ -23,6 +23,15 @@ async function getReader(): Promise<Reader | null> {
}
}
export function resetGeoReader() {
_reader = null;
_loadAttempted = false;
}
export function geoStatus(): { available: boolean; path: string } {
return { available: fs.existsSync(MMDB_PATH), path: MMDB_PATH };
}
export async function lookupCountry(ip: string): Promise<string | null> {
const r = await getReader();
if (!r) return null;