fix(admin): truncate and copy the login-overview IP address

A long IPv6 in the login overview forced the table to scroll sideways and
the full address was awkward to lift out. Cap the cell, ellipsize the
address, and make it a button that copies the full value to the clipboard
with a transient checkmark and a toast — keyboard-activatable, with an
accessible label. The empty case stays plain text.
This commit is contained in:
Marc-André Bombeck committed 2026-06-30 18:24:15 +02:00
1 parent 45ca6ff24b
commit 2ed73a3a34
7 files changed
+75 -7

No files matched your search

+4 -1
View File
@@ -5883,7 +5883,10 @@
"mfaEnforcedOn": "MFA an",
"mfaEnforcedOff": "MFA aus",
"mfaEnforcedOnHint": "Für diesen Nutzer ist ein zweiter Faktor erforderlich. Klicken, um die Anforderung aufzuheben.",
"mfaEnforcedOffHint": "Einen zweiten Faktor für diesen Nutzer verlangen."
"mfaEnforcedOffHint": "Einen zweiten Faktor für diesen Nutzer verlangen.",
"copyIp": "IP-Adresse kopieren",
"ipCopied": "IP-Adresse kopiert",
"copyFailed": "Kopieren fehlgeschlagen."
},
"achievements": {
"title": "Erfolge",
+4 -1
View File
@@ -5883,7 +5883,10 @@
"mfaEnforcedOn": "MFA on",
"mfaEnforcedOff": "MFA off",
"mfaEnforcedOnHint": "A second factor is required for this user. Click to stop requiring it.",
"mfaEnforcedOffHint": "Require a second factor for this user."
"mfaEnforcedOffHint": "Require a second factor for this user.",
"copyIp": "Copy IP address",
"ipCopied": "IP address copied",
"copyFailed": "Could not copy."
},
"achievements": {
"title": "Achievements",
+4 -1
View File
@@ -5883,7 +5883,10 @@
"mfaEnforcedOn": "MFA activado",
"mfaEnforcedOff": "MFA desactivado",
"mfaEnforcedOnHint": "Este usuario debe usar un segundo factor. Haz clic para dejar de exigirlo.",
"mfaEnforcedOffHint": "Exigir un segundo factor a este usuario."
"mfaEnforcedOffHint": "Exigir un segundo factor a este usuario.",
"copyIp": "Copiar dirección IP",
"ipCopied": "Dirección IP copiada",
"copyFailed": "No se pudo copiar."
},
"achievements": {
"title": "Logros",
+4 -1
View File
@@ -5883,7 +5883,10 @@
"mfaEnforcedOn": "MFA activé",
"mfaEnforcedOff": "MFA désactivé",
"mfaEnforcedOnHint": "Un second facteur est requis pour cet utilisateur. Cliquez pour ne plus l'exiger.",
"mfaEnforcedOffHint": "Exiger un second facteur pour cet utilisateur."
"mfaEnforcedOffHint": "Exiger un second facteur pour cet utilisateur.",
"copyIp": "Copier l’adresse IP",
"ipCopied": "Adresse IP copiée",
"copyFailed": "Impossible de copier."
},
"achievements": {
"title": "Réussites",
+4 -1
View File
@@ -5883,7 +5883,10 @@
"mfaEnforcedOn": "MFA attivo",
"mfaEnforcedOff": "MFA disattivato",
"mfaEnforcedOnHint": "Per questo utente è richiesto un secondo fattore. Clicca per non richiederlo più.",
"mfaEnforcedOffHint": "Richiedi un secondo fattore per questo utente."
"mfaEnforcedOffHint": "Richiedi un secondo fattore per questo utente.",
"copyIp": "Copia indirizzo IP",
"ipCopied": "Indirizzo IP copiato",
"copyFailed": "Impossibile copiare."
},
"achievements": {
"title": "Obiettivi",
+4 -1
View File
@@ -5883,7 +5883,10 @@
"mfaEnforcedOn": "MFA wł.",
"mfaEnforcedOff": "MFA wył.",
"mfaEnforcedOnHint": "Ten użytkownik musi używać drugiego składnika. Kliknij, aby przestać go wymagać.",
"mfaEnforcedOffHint": "Wymagaj drugiego składnika dla tego użytkownika."
"mfaEnforcedOffHint": "Wymagaj drugiego składnika dla tego użytkownika.",
"copyIp": "Kopiuj adres IP",
"ipCopied": "Skopiowano adres IP",
"copyFailed": "Nie udało się skopiować."
},
"achievements": {
"title": "Osiągnięcia",
@@ -2,8 +2,11 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Check,
CheckCircle2,
Copy,
Download,
Loader2,
MapPin,
@@ -94,6 +97,25 @@ export function LoginOverviewSection() {
const AUTH_ACTION_LABELS = useAuthActionLabels();
const AUTH_PROVIDER_LABELS = useAuthProviderLabels();
// v1.25.11 (#151) — long IPv6 addresses are truncated in the table cell so
// they never force the wrapper into horizontal scroll; the full value is one
// click away. Track the most-recently-copied row so the icon can flip to a
// checkmark for transient feedback.
const [copiedIp, setCopiedIp] = useState<string | null>(null);
async function copyIp(id: string, ip: string) {
try {
await navigator.clipboard.writeText(ip);
setCopiedIp(id);
toast.success(t("admin.ipCopied"));
window.setTimeout(() => {
setCopiedIp((current) => (current === id ? null : current));
}, 2_000);
} catch {
toast.error(t("admin.copyFailed"));
}
}
// Build the query string once so it's reused by the data-query key, the
// export download, and the next-/prev- buttons.
const queryParams = useMemo(() => {
@@ -464,7 +486,35 @@ export function LoginOverviewSection() {
</span>
</td>
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
{entry.ipAddress ?? "—"}
{entry.ipAddress ? (
<button
type="button"
onClick={() =>
copyIp(entry.id, entry.ipAddress as string)
}
title={entry.ipAddress}
aria-label={t("admin.copyIp")}
data-slot="login-overview-ip"
className="hover:text-foreground focus-visible:ring-ring inline-flex max-w-[10rem] items-center gap-1 rounded font-mono focus-visible:ring-2 focus-visible:outline-none"
>
<span className="truncate">
{entry.ipAddress}
</span>
{copiedIp === entry.id ? (
<Check
className="text-dracula-green h-3 w-3 shrink-0"
aria-hidden="true"
/>
) : (
<Copy
className="h-3 w-3 shrink-0 opacity-60"
aria-hidden="true"
/>
)}
</button>
) : (
"—"
)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{entry.location ?? "—"}