v0.1.2 — UX fixes: dark dropdowns, @ for root in DNS table, copy fallback, geo settings UI
This commit is contained in:
parent
c06a16d86e
commit
26725f9f15
6 changed files with 249 additions and 44 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
69
app/api/settings/geo/route.ts
Normal file
69
app/api/settings/geo/route.ts
Normal 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
26
lib/clipboard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue