mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
chore(release): v1.4.27
v1.4.27 — Mobile capability and maintainer-finding cleanup. ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop) applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer (graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line release notes attached to the GitHub Release.
This commit is contained in:
1 parent
d98e85cd03
commit
4f09c3f862
314 files changed
+26801
-4419
No files matched your search
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB5 — `/admin/app-logs` summary line lifted out of the
|
||||
* `overflow-x-auto` table wrapper so admins on narrow viewports can
|
||||
* still see "showing X of Y events" without first scrolling the table
|
||||
* back to its starting offset. The refresh control already lives in
|
||||
* the section header.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/app-logs",
|
||||
}));
|
||||
|
||||
const sampleEvents = [
|
||||
{
|
||||
request_id: "req-1",
|
||||
trace_id: "abcdef1234567890",
|
||||
level: "info" as const,
|
||||
timestamp: "2026-05-10T09:00:00Z",
|
||||
duration_ms: 12,
|
||||
action: { name: "auth.login.success" },
|
||||
kind: "request",
|
||||
http: { method: "POST", path: "/api/auth/login" },
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
events: sampleEvents,
|
||||
meta: { total: 1, bufferMax: 500 },
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
isFetching: false,
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "ADMIN",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AppLogPreviewSection } from "../app-log-preview-section";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AppLogPreviewSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AppLogPreviewSection — summary lives outside the scroll wrapper", () => {
|
||||
it("renders the summary row with the stable test id", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="app-log-preview-summary"');
|
||||
});
|
||||
|
||||
it("places the summary row outside any `overflow-x-auto` container", () => {
|
||||
const html = render();
|
||||
const summaryIdx = html.indexOf('data-testid="app-log-preview-summary"');
|
||||
expect(summaryIdx).toBeGreaterThan(-1);
|
||||
const before = html.slice(0, summaryIdx);
|
||||
const lastScrollOpen = before.lastIndexOf('class="overflow-x-auto"');
|
||||
expect(lastScrollOpen).toBeGreaterThan(-1);
|
||||
const tail = before.slice(lastScrollOpen);
|
||||
expect(tail).toContain("</div>");
|
||||
});
|
||||
|
||||
it("does not nest the summary copy inside the scroll wrapper", () => {
|
||||
const html = render();
|
||||
const wrapper = html.match(
|
||||
/<div class="overflow-x-auto"[^>]*>[\s\S]*?<\/table><\/div>/,
|
||||
);
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper![0]).not.toContain("app-log-preview-summary");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
auditLogCsvHeaderLabels,
|
||||
buildAuditLogCsvRecords,
|
||||
carrierShortLabel,
|
||||
type AuditCsvEntry,
|
||||
type AuditCsvLabels,
|
||||
} from "../_shared";
|
||||
@@ -30,6 +31,9 @@ const LABELS_EN: AuditCsvLabels = {
|
||||
user: "Users",
|
||||
ip: "IP",
|
||||
location: "Location",
|
||||
// v1.4.27 B3 — `admin.carrier` is shipped by bucket B6; the test
|
||||
// pins the English literal so the column order is locked.
|
||||
carrier: "Carrier",
|
||||
provider: "Provider",
|
||||
outcome: "Outcome",
|
||||
action: "Action",
|
||||
@@ -52,6 +56,7 @@ const LABELS_DE: AuditCsvLabels = {
|
||||
user: "Benutzer",
|
||||
ip: "IP",
|
||||
location: "Standort",
|
||||
carrier: "Mobilfunkanbieter",
|
||||
provider: "Anbieter",
|
||||
outcome: "Ergebnis",
|
||||
action: "Aktion",
|
||||
@@ -98,6 +103,7 @@ describe("buildAuditLogCsvRecords", () => {
|
||||
"user",
|
||||
"ip",
|
||||
"location",
|
||||
"carrier",
|
||||
"provider",
|
||||
"outcome",
|
||||
"action",
|
||||
@@ -196,6 +202,7 @@ describe("auditLogCsvHeaderLabels", () => {
|
||||
user: "Users",
|
||||
ip: "IP",
|
||||
location: "Location",
|
||||
carrier: "Carrier",
|
||||
provider: "Provider",
|
||||
outcome: "Outcome",
|
||||
action: "Action",
|
||||
@@ -224,13 +231,105 @@ describe("toCSV(records, headerLabels) — audit-log integration", () => {
|
||||
const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_DE));
|
||||
const lines = csv.split("\n");
|
||||
expect(lines[0]).toBe(
|
||||
"Zeitpunkt,Benutzer,IP,Standort,Anbieter,Ergebnis,Aktion,Details",
|
||||
"Zeitpunkt,Benutzer,IP,Standort,Mobilfunkanbieter,Anbieter,Ergebnis,Aktion,Details",
|
||||
);
|
||||
expect(lines[1]).toBe(
|
||||
'2026-05-11T09:05:00+00:00,marc,203.0.113.7,"Berlin, DE",Passkey,Erfolgreich,auth.login.passkey,',
|
||||
'2026-05-11T09:05:00+00:00,marc,203.0.113.7,"Berlin, DE",,Passkey,Erfolgreich,auth.login.passkey,',
|
||||
);
|
||||
});
|
||||
|
||||
it("folds the GeoLite2 organisation string to the short DACH carrier label", () => {
|
||||
// Marc's spec: the verbose MMDB org strings get a glanceable chip.
|
||||
// The CSV column carries the same folded text so a downstream
|
||||
// spreadsheet matches what the admin overview screen renders.
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: "84.131.0.1",
|
||||
location: "Berlin, DE",
|
||||
carrier: "Deutsche Telekom AG",
|
||||
asn: 3320,
|
||||
details: null,
|
||||
user: { id: "u1", username: "marc" },
|
||||
},
|
||||
{
|
||||
createdAt: "2026-05-11T09:06:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: "139.7.0.1",
|
||||
location: "Hamburg, DE",
|
||||
carrier: "Vodafone GmbH",
|
||||
asn: 3209,
|
||||
details: null,
|
||||
user: { id: "u2", username: "marc" },
|
||||
},
|
||||
{
|
||||
createdAt: "2026-05-11T09:07:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: "212.7.0.1",
|
||||
location: "Köln, DE",
|
||||
carrier: "1&1 Versatel GmbH",
|
||||
asn: 8881,
|
||||
details: null,
|
||||
user: { id: "u3", username: "marc" },
|
||||
},
|
||||
{
|
||||
createdAt: "2026-05-11T09:08:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: "92.7.0.1",
|
||||
location: "Frankfurt, DE",
|
||||
carrier: "Telefónica Germany GmbH & Co. OHG",
|
||||
asn: 6805,
|
||||
details: null,
|
||||
user: { id: "u4", username: "marc" },
|
||||
},
|
||||
{
|
||||
createdAt: "2026-05-11T09:09:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: "8.8.8.8",
|
||||
location: "Mountain View, US",
|
||||
carrier: "Google LLC",
|
||||
asn: 15169,
|
||||
details: null,
|
||||
user: { id: "u5", username: "marc" },
|
||||
},
|
||||
];
|
||||
const records = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_EN,
|
||||
deterministicFormatter,
|
||||
);
|
||||
expect(records.map((r) => r.carrier)).toEqual([
|
||||
"Telekom",
|
||||
"Vodafone",
|
||||
"1&1",
|
||||
"O2",
|
||||
// Unknown organisations carry the raw GeoLite2 string through.
|
||||
"Google LLC",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits an empty carrier cell when the row has no ASN data", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: "192.0.2.1",
|
||||
location: null,
|
||||
carrier: null,
|
||||
asn: null,
|
||||
details: null,
|
||||
user: null,
|
||||
},
|
||||
];
|
||||
const [record] = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_EN,
|
||||
deterministicFormatter,
|
||||
);
|
||||
expect(record.carrier).toBe("");
|
||||
});
|
||||
|
||||
it("escapes commas, quotes, and newlines in the details column", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
@@ -256,3 +355,35 @@ describe("toCSV(records, headerLabels) — audit-log integration", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => {
|
||||
it("folds 'Deutsche Telekom AG' down to 'Telekom'", () => {
|
||||
expect(carrierShortLabel("Deutsche Telekom AG")).toBe("Telekom");
|
||||
});
|
||||
|
||||
it("folds the standalone Vodafone variants down to 'Vodafone'", () => {
|
||||
expect(carrierShortLabel("Vodafone GmbH")).toBe("Vodafone");
|
||||
expect(carrierShortLabel("Vodafone Kabel Deutschland GmbH")).toBe(
|
||||
"Vodafone",
|
||||
);
|
||||
});
|
||||
|
||||
it("folds the 1&1 sub-brands down to '1&1'", () => {
|
||||
expect(carrierShortLabel("1&1 Telecom GmbH")).toBe("1&1");
|
||||
expect(carrierShortLabel("1&1 Versatel GmbH")).toBe("1&1");
|
||||
});
|
||||
|
||||
it("folds Telefónica + O2 variants down to 'O2'", () => {
|
||||
expect(
|
||||
carrierShortLabel("Telefónica Germany GmbH & Co. OHG"),
|
||||
).toBe("O2");
|
||||
expect(carrierShortLabel("Telefonica Germany Online Services")).toBe("O2");
|
||||
expect(carrierShortLabel("O2 Deutschland")).toBe("O2");
|
||||
});
|
||||
|
||||
it("returns the raw organisation string for unknown carriers", () => {
|
||||
expect(carrierShortLabel("Google LLC")).toBe("Google LLC");
|
||||
expect(carrierShortLabel("Amazon.com, Inc.")).toBe("Amazon.com, Inc.");
|
||||
expect(carrierShortLabel("Hetzner Online GmbH")).toBe("Hetzner Online GmbH");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB5 — `/admin/login-overview` pagination + summary lifted
|
||||
* out of the `overflow-x-auto` table wrapper.
|
||||
*
|
||||
* The audit table can be wider than the viewport on phones, so it
|
||||
* stays in an `overflow-x-auto` container that pans horizontally on
|
||||
* touch. Before this fix the prev/next controls lived *inside* that
|
||||
* same wrapper, meaning admins on narrow viewports had to scroll the
|
||||
* table back to its starting offset before they could reach the
|
||||
* pagination controls. The summary "showing X of Y" line was hidden
|
||||
* the same way. The fix moves both into a sibling block under the
|
||||
* scroll container.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/login-overview",
|
||||
}));
|
||||
|
||||
const sampleEntries = [
|
||||
{
|
||||
id: "ev-1",
|
||||
user: { id: "u1", username: "marc" },
|
||||
action: "auth.login.success",
|
||||
ipAddress: "203.0.113.10",
|
||||
location: "Berlin, DE",
|
||||
carrier: null,
|
||||
createdAt: "2026-05-10T09:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
entries: sampleEntries,
|
||||
meta: { total: 23, limit: 50, offset: 0, page: 1, perPage: 50 },
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "ADMIN",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { LoginOverviewSection } from "../login-overview-section";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<LoginOverviewSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LoginOverviewSection — pagination lives outside the scroll wrapper", () => {
|
||||
it("renders the pagination row with the stable test id", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="login-overview-pagination"');
|
||||
});
|
||||
|
||||
it("places the pagination row outside any `overflow-x-auto` container", () => {
|
||||
const html = render();
|
||||
// Capture the pagination block and assert that the rendered
|
||||
// markup leading up to its opening tag closes the scroll
|
||||
// container first. The simplest invariant: there is no
|
||||
// `<div class="overflow-x-auto">` ancestor that is still open
|
||||
// when the pagination div opens. We approximate this by finding
|
||||
// the last `<div class="overflow-x-auto"` before the pagination
|
||||
// div and confirming the inner content closes (matching `</div>`)
|
||||
// before the pagination div opens.
|
||||
const paginationIdx = html.indexOf(
|
||||
'data-testid="login-overview-pagination"',
|
||||
);
|
||||
expect(paginationIdx).toBeGreaterThan(-1);
|
||||
const before = html.slice(0, paginationIdx);
|
||||
const lastScrollOpen = before.lastIndexOf('class="overflow-x-auto"');
|
||||
expect(lastScrollOpen).toBeGreaterThan(-1);
|
||||
// Between the scroll-wrapper opening and the pagination opening
|
||||
// there must be at least one `</div>` closing the wrapper.
|
||||
const tail = before.slice(lastScrollOpen);
|
||||
expect(tail).toContain("</div>");
|
||||
});
|
||||
|
||||
it("does not nest the prev/next buttons inside the scroll wrapper", () => {
|
||||
const html = render();
|
||||
// Pin the entire scroll wrapper and confirm the prev/next button
|
||||
// labels are not within it. We slice the markup between the
|
||||
// wrapper's opening tag and its first balanced closing tag at the
|
||||
// same depth — naively by grabbing up to `</table></div>`, which
|
||||
// matches the structure produced by the current implementation.
|
||||
const wrapper = html.match(
|
||||
/<div class="overflow-x-auto"[^>]*>[\s\S]*?<\/table><\/div>/,
|
||||
);
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper![0]).not.toContain("admin.section.auditLog.next");
|
||||
// The English labels also must not appear inside the wrapper.
|
||||
expect(wrapper![0]).not.toMatch(/>\s*Next\s*</);
|
||||
expect(wrapper![0]).not.toMatch(/>\s*Previous\s*</);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { toast } from "sonner";
|
||||
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PasswordInput as SettingsPasswordInput } from "@/components/settings/password-input";
|
||||
import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
/**
|
||||
* Re-export the canonical password input so admin sections have access to
|
||||
@@ -15,7 +15,7 @@ import { PasswordInput as SettingsPasswordInput } from "@/components/settings/pa
|
||||
* `button-name` violation on `/admin/integrations` and
|
||||
* `/admin/users` (axe-core, WCAG 2.1.2).
|
||||
*/
|
||||
export const PasswordInput = SettingsPasswordInput;
|
||||
export const PasswordInput = SharedPasswordInput;
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
@@ -92,6 +92,11 @@ export interface AdminAuditEntry {
|
||||
action: string;
|
||||
ipAddress: string | null;
|
||||
location: string | null;
|
||||
// v1.4.27 B3 — populated from the bundled GeoLite2-ASN MMDB at
|
||||
// audit-creation time. Both nullable: older rows, private/loopback
|
||||
// IPs, and offline-miss rows stay valid.
|
||||
asn: number | null;
|
||||
carrier: string | null;
|
||||
details: string | null;
|
||||
createdAt: string;
|
||||
user: { id: string; username: string } | null;
|
||||
@@ -190,11 +195,18 @@ export function SettingsToggle({
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
// v1.4.27 MB7 / CF-56 — stack the toggle row vertically on `<sm` so
|
||||
// the label + description don't have to compete with the switch for
|
||||
// horizontal space on Pixel 5 (the German "Registration enabled — …"
|
||||
// description used to wrap to 4 lines while the switch ate ~52 px on
|
||||
// the right). At `sm:` the original side-by-side layout returns.
|
||||
// The switch self-aligns to the right via `self-end` on the stacked
|
||||
// branch so it stays a familiar tap target.
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="text-muted-foreground h-4 w-4" />}
|
||||
<div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{Icon && <Icon className="text-muted-foreground h-4 w-4 shrink-0" />}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-xs">{description}</p>
|
||||
@@ -205,6 +217,7 @@ export function SettingsToggle({
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
className="self-end sm:self-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -380,6 +393,30 @@ export function useAuthProviderLabels(): Record<AuthProvider, string> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.27 B3 — Fold a GeoLite2 `autonomous_system_organization`
|
||||
* string down to a short DACH carrier label. The MMDB returns the
|
||||
* verbose legal-entity name ("Deutsche Telekom AG", "1&1 Telecom
|
||||
* GmbH", "Telefónica Germany GmbH & Co. OHG"); the admin overview
|
||||
* needs a glanceable chip ("Telekom", "Vodafone", "1&1", "O2").
|
||||
*
|
||||
* The match is case-insensitive substring against the canonical
|
||||
* carrier name; unknown organisations fall through to the original
|
||||
* string so the chip still carries provenance.
|
||||
*/
|
||||
export function carrierShortLabel(rawAsnOrg: string): string {
|
||||
const haystack = rawAsnOrg.toLowerCase();
|
||||
if (haystack.includes("telekom")) return "Telekom";
|
||||
if (haystack.includes("vodafone")) return "Vodafone";
|
||||
if (haystack.includes("1&1")) return "1&1";
|
||||
// Telefónica / O2 / Telefonica Germany / O2 Deutschland → "O2"
|
||||
if (haystack.includes("telefonica") || haystack.includes("telefónica")) {
|
||||
return "O2";
|
||||
}
|
||||
if (haystack.includes("o2 ") || haystack.endsWith(" o2")) return "O2";
|
||||
return rawAsnOrg;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W8b — Build the CSV record set for the audit-log export.
|
||||
*
|
||||
@@ -391,6 +428,9 @@ export function useAuthProviderLabels(): Record<AuthProvider, string> {
|
||||
* require schema/API changes that are out of scope). `action` and
|
||||
* `details` follow so the export keeps the full triage payload.
|
||||
*
|
||||
* v1.4.27 B3: the `carrier` column lands between `location` and
|
||||
* `provider` so the geo-derived fields stay grouped.
|
||||
*
|
||||
* `formatTimestamp` is injected (not imported) so the production
|
||||
* callsite passes the user-tz formatter (`formatInUserTz`) while the
|
||||
* unit test passes a deterministic stub.
|
||||
@@ -400,6 +440,10 @@ export interface AuditCsvEntry {
|
||||
action: string;
|
||||
ipAddress: string | null;
|
||||
location: string | null;
|
||||
// v1.4.27 B3 — both optional so legacy fixture data that predates
|
||||
// the column landing still compiles against this shape.
|
||||
carrier?: string | null;
|
||||
asn?: number | null;
|
||||
details: string | null;
|
||||
user: { id: string; username: string } | null;
|
||||
}
|
||||
@@ -409,6 +453,7 @@ export interface AuditCsvLabels {
|
||||
user: string;
|
||||
ip: string;
|
||||
location: string;
|
||||
carrier: string;
|
||||
provider: string;
|
||||
outcome: string;
|
||||
action: string;
|
||||
@@ -424,6 +469,7 @@ export interface AuditCsvRecord {
|
||||
user: string;
|
||||
ip: string;
|
||||
location: string;
|
||||
carrier: string;
|
||||
provider: string;
|
||||
outcome: string;
|
||||
action: string;
|
||||
@@ -450,6 +496,7 @@ export function buildAuditLogCsvRecords(
|
||||
user: entry.user?.username ?? labels.unknownUser,
|
||||
ip: entry.ipAddress ?? "",
|
||||
location: entry.location ?? "",
|
||||
carrier: entry.carrier ? carrierShortLabel(entry.carrier) : "",
|
||||
provider: labels.providerLabels[provider],
|
||||
outcome: isFailed ? labels.outcomeFailed : labels.outcomeSuccess,
|
||||
action: entry.action,
|
||||
@@ -466,6 +513,7 @@ export function auditLogCsvHeaderLabels(
|
||||
user: labels.user,
|
||||
ip: labels.ip,
|
||||
location: labels.location,
|
||||
carrier: labels.carrier,
|
||||
provider: labels.provider,
|
||||
outcome: labels.outcome,
|
||||
action: labels.action,
|
||||
|
||||
@@ -263,8 +263,12 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main column — page renders its own h1 + subtitle */}
|
||||
<main className="min-w-0">{children}</main>
|
||||
{/* Main column — page renders its own h1 + subtitle. Same
|
||||
`min-h-[calc(100dvh-12rem)]` reserve as `<SettingsShell>`
|
||||
so navigating between admin sub-pages (e.g. system-status
|
||||
→ login-overview) does not jump the page height while the
|
||||
new section is still fetching. */}
|
||||
<main className="min-h-[calc(100dvh-12rem)] min-w-0">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ export function AiQualitySection() {
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.aiQuality.loading")}
|
||||
</span>
|
||||
|
||||
@@ -133,7 +133,7 @@ export function AppLogPreviewSection() {
|
||||
aria-label={t("admin.section.app-logs.refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
|
||||
/>
|
||||
</Button>
|
||||
</header>
|
||||
@@ -195,7 +195,7 @@ export function AppLogPreviewSection() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<EmptyState
|
||||
@@ -204,54 +204,64 @@ export function AppLogPreviewSection() {
|
||||
description={t("admin.section.app-logs.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colLevel")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colTimestamp")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colAction")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-right font-medium">
|
||||
{t("admin.section.app-logs.colDuration")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colTraceId")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{events.map((event, i) => (
|
||||
<tr
|
||||
key={`${event.request_id}-${i}`}
|
||||
className={`${i % 2 === 0 ? "bg-muted/30" : ""} hover:bg-muted cursor-pointer`}
|
||||
onClick={() => setSelected(event)}
|
||||
>
|
||||
<td className="px-2 py-2">{levelIcon(event.level)}</td>
|
||||
<td className="text-muted-foreground px-2 py-2 text-xs whitespace-nowrap">
|
||||
{formatDateTime(event.timestamp)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs">
|
||||
{event.action?.name ??
|
||||
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
|
||||
event.kind)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-2 py-2 text-right font-mono text-xs">
|
||||
{event.duration_ms} ms
|
||||
</td>
|
||||
<td className="text-muted-foreground px-2 py-2 font-mono text-xs">
|
||||
{event.trace_id.slice(0, 8)}…
|
||||
</td>
|
||||
// v1.4.27 MB5 — keep the table scroll container, but lift the
|
||||
// "showing X of Y" summary line out into a sibling `<div>` so
|
||||
// the meta info stays reachable on narrow viewports even when
|
||||
// the table is scrolled horizontally. The refresh control was
|
||||
// already in the header above the table wrapper.
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colLevel")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colTimestamp")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colAction")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-right font-medium">
|
||||
{t("admin.section.app-logs.colDuration")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colTraceId")}
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{events.map((event, i) => (
|
||||
<tr
|
||||
key={`${event.request_id}-${i}`}
|
||||
className={`${i % 2 === 0 ? "bg-muted/30" : ""} hover:bg-muted cursor-pointer`}
|
||||
onClick={() => setSelected(event)}
|
||||
>
|
||||
<td className="px-2 py-2">{levelIcon(event.level)}</td>
|
||||
<td className="text-muted-foreground px-2 py-2 text-xs whitespace-nowrap">
|
||||
{formatDateTime(event.timestamp)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs">
|
||||
{event.action?.name ??
|
||||
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
|
||||
event.kind)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-2 py-2 text-right font-mono text-xs">
|
||||
{event.duration_ms} ms
|
||||
</td>
|
||||
<td className="text-muted-foreground px-2 py-2 font-mono text-xs">
|
||||
{event.trace_id.slice(0, 8)}…
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground mt-3 flex items-center justify-between text-xs"
|
||||
data-testid="app-log-preview-summary"
|
||||
>
|
||||
<span>
|
||||
{t("admin.section.app-logs.showing", {
|
||||
count: events.length,
|
||||
@@ -259,7 +269,7 @@ export function AppLogPreviewSection() {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
|
||||
@@ -90,7 +90,7 @@ function RestoreRowDialog({
|
||||
className="min-h-11"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<History className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
|
||||
@@ -119,7 +119,7 @@ export function BugReportSection() {
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -99,7 +99,7 @@ export function CoachFeedbackSection() {
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.coachFeedback.loading")}
|
||||
</span>
|
||||
|
||||
@@ -85,7 +85,7 @@ export function DangerZoneSection() {
|
||||
disabled={wipeAllData.isPending}
|
||||
>
|
||||
{wipeAllData.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
|
||||
@@ -25,13 +25,7 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
@@ -85,26 +79,34 @@ export function FeedbackInboxSection() {
|
||||
onValueChange={(v) => setActiveStatus(v as FeedbackStatusType)}
|
||||
className="mt-4"
|
||||
>
|
||||
<TabsList>
|
||||
{FEEDBACK_STATUS_TABS.map((s) => (
|
||||
<TabsTrigger key={s} value={s}>
|
||||
<span>
|
||||
{t(
|
||||
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{counts[s] ?? 0}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{/* v1.4.27 MB7 / CF-55 — wrap the TabsList in an
|
||||
overflow-x-auto strip so the 4 status pills + their count
|
||||
badges scroll horizontally on Pixel 5 / Galaxy Fold
|
||||
instead of squashing the badge text below the legibility
|
||||
floor. Hide the scrollbar visually but keep the gesture
|
||||
working (matches the Insights tab-strip pattern). */}
|
||||
<div className="-mx-1 overflow-x-auto px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<TabsList>
|
||||
{FEEDBACK_STATUS_TABS.map((s) => (
|
||||
<TabsTrigger key={s} value={s}>
|
||||
<span>
|
||||
{t(
|
||||
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{counts[s] ?? 0}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{FEEDBACK_STATUS_TABS.map((s) => (
|
||||
<TabsContent key={s} value={s} className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.feedback.loading")}
|
||||
</span>
|
||||
@@ -321,38 +323,110 @@ function FeedbackDetailDialog({
|
||||
typeof meta.appVersion === "string" ? meta.appVersion : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
{item.subject}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
<span>
|
||||
{t("admin.feedback.submittedBy")}:{" "}
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatDateTime(item.createdAt)}</span>
|
||||
{issueUrl && (
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1"
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
{item.subject}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
<span>
|
||||
{t("admin.feedback.submittedBy")}:{" "}
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatDateTime(item.createdAt)}</span>
|
||||
{issueUrl && (
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1"
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
{t("admin.feedback.viewIssue")}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
className="sm:max-w-2xl"
|
||||
footer={
|
||||
<div className="flex w-full flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
|
||||
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionAcknowledge")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={update.isPending || item.status === "RESOLVED"}
|
||||
onClick={() => update.mutate({ status: "RESOLVED" })}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionResolve")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={archive.isPending || item.status === "ARCHIVED"}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.feedback.archiveConfirm")}
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => archive.mutate()}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
{t("admin.feedback.viewIssue")}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{githubConfigured && !issueUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={publish.isPending}
|
||||
onClick={() => publish.mutate()}
|
||||
className="ml-auto"
|
||||
>
|
||||
{publish.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{t("admin.feedback.actionPublishGithub")}
|
||||
</Button>
|
||||
)}
|
||||
{issueUrl && (
|
||||
<Badge className="border-success/30 bg-success/15 text-success ml-auto">
|
||||
{t("admin.feedback.publishedToGithub")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
|
||||
{item.description}
|
||||
@@ -435,83 +509,14 @@ function FeedbackDetailDialog({
|
||||
onClick={() => update.mutate({ adminNote: note || null })}
|
||||
>
|
||||
{update.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.feedback.saveNote")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
|
||||
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionAcknowledge")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={update.isPending || item.status === "RESOLVED"}
|
||||
onClick={() => update.mutate({ status: "RESOLVED" })}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionResolve")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={archive.isPending || item.status === "ARCHIVED"}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.feedback.archiveConfirm")}
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => archive.mutate()}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{githubConfigured && !issueUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={publish.isPending}
|
||||
onClick={() => publish.mutate()}
|
||||
className="ml-auto"
|
||||
>
|
||||
{publish.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.feedback.actionPublishGithub")}
|
||||
</Button>
|
||||
)}
|
||||
{issueUrl && (
|
||||
<Badge className="border-success/30 bg-success/15 text-success ml-auto">
|
||||
{t("admin.feedback.publishedToGithub")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,16 @@
|
||||
import { useMemo } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import { listSupportedTimezones } from "@/lib/tz/format";
|
||||
|
||||
const NATIVE_SELECT_CLASS =
|
||||
"border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none";
|
||||
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
|
||||
// retired; the shared `<NativeSelect>` primitive owns the visual
|
||||
// contract now. The admin copy previously diverged on the
|
||||
// `focus-visible:ring-[3px]` weight; the shared primitive pins
|
||||
// `focus-visible:ring-2` so admin selects match the rest of the form
|
||||
// surface.
|
||||
|
||||
export function GeneralSettingsSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -36,24 +41,26 @@ export function GeneralSettingsSection() {
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{/* v1.4.27 MB7 / CF-56 — match the SettingsToggle stacking
|
||||
contract: stack on `<sm`, side-by-side on `sm+`. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t("admin.defaultLanguage")}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.defaultLanguageDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
<NativeSelect
|
||||
value={settings?.defaultLocale ?? "de"}
|
||||
onChange={(e) =>
|
||||
updateSettings.mutate({ defaultLocale: e.target.value })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
className={NATIVE_SELECT_CLASS}
|
||||
className="self-end sm:w-auto sm:self-auto"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{/* v1.4.25 W7 — server-wide default timezone for new
|
||||
@@ -69,13 +76,13 @@ export function GeneralSettingsSection() {
|
||||
{t("admin.defaultUserTimezoneDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
<NativeSelect
|
||||
value={currentDefaultTz}
|
||||
onChange={(e) =>
|
||||
updateSettings.mutate({ defaultUserTimezone: e.target.value })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
className={NATIVE_SELECT_CLASS}
|
||||
className="sm:w-auto"
|
||||
>
|
||||
<option value="">{t("admin.defaultUserTimezoneFallback")}</option>
|
||||
{/* Preserve the stored value even if it's not in the
|
||||
@@ -88,7 +95,7 @@ export function GeneralSettingsSection() {
|
||||
{zone}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -148,7 +148,7 @@ export function GlitchtipSection() {
|
||||
disabled={testGlitchtip.isPending || updateSettings.isPending}
|
||||
>
|
||||
{testGlitchtip.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.test")}
|
||||
</Button>
|
||||
@@ -158,7 +158,7 @@ export function GlitchtipSection() {
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type AdminAuditEntry,
|
||||
auditLogCsvHeaderLabels,
|
||||
buildAuditLogCsvRecords,
|
||||
carrierShortLabel,
|
||||
iconForAuthProvider,
|
||||
providerForAction,
|
||||
useAuthActionLabels,
|
||||
@@ -159,6 +160,11 @@ export function LoginOverviewSection() {
|
||||
user: t("admin.users"),
|
||||
ip: t("admin.ip"),
|
||||
location: t("admin.location"),
|
||||
// v1.4.27 B3 — `admin.carrier` is shipped by bucket B6. The
|
||||
// `t()` helper falls back to the raw key string when the
|
||||
// translation row hasn't landed yet, so the CSV stays valid
|
||||
// through the staggered B3 → B6 release.
|
||||
carrier: t("admin.carrier"),
|
||||
provider: t("admin.provider"),
|
||||
outcome: t("admin.outcome"),
|
||||
action: t("admin.action"),
|
||||
@@ -334,7 +340,7 @@ export function LoginOverviewSection() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : !entries.length ? (
|
||||
<EmptyState
|
||||
@@ -362,26 +368,34 @@ export function LoginOverviewSection() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.action")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.provider")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.ip")}
|
||||
</th>
|
||||
{/*
|
||||
// v1.4.27 MB5 — the audit table itself stays in an
|
||||
// `overflow-x-auto` scroll container so wide rows can pan
|
||||
// horizontally on phones, but the pagination + summary line
|
||||
// moved out into a sibling `<div>` below so the prev/next
|
||||
// controls stay reachable without first scrolling the table
|
||||
// back to its starting offset. The CSV-export button was
|
||||
// already in the toolbar row above the table wrapper.
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.action")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.provider")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.ip")}
|
||||
</th>
|
||||
{/*
|
||||
v1.4.25 W8b — Standort column. Previously rendered
|
||||
`text-right`, which on wide audit tables let the
|
||||
`Berlin, DE` label drift to the table edge and
|
||||
@@ -390,68 +404,90 @@ export function LoginOverviewSection() {
|
||||
consistency) makes Standort a first-class column
|
||||
that admins actually notice.
|
||||
*/}
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" aria-hidden="true" />
|
||||
{t("admin.location")}
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.timestamp")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{entries.map((entry, i) => {
|
||||
const isFailed = entry.action === "auth.login.failed";
|
||||
const provider = providerForAction(entry.action);
|
||||
const ProviderIcon = iconForAuthProvider(provider);
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : ""}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
{isFailed ? (
|
||||
<XCircle className="text-destructive h-4 w-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="text-dracula-green h-4 w-4" />
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" aria-hidden="true" />
|
||||
{t("admin.location")}
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.timestamp")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{entries.map((entry, i) => {
|
||||
const isFailed = entry.action === "auth.login.failed";
|
||||
const provider = providerForAction(entry.action);
|
||||
const ProviderIcon = iconForAuthProvider(provider);
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : ""}
|
||||
>
|
||||
{entry.user?.username ?? t("common.unknown")}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProviderIcon
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{AUTH_PROVIDER_LABELS[provider]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
|
||||
{entry.ipAddress ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{entry.location ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDateTime(entry.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<td className="px-3 py-2">
|
||||
{isFailed ? (
|
||||
<XCircle className="text-destructive h-4 w-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="text-dracula-green h-4 w-4" />
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
|
||||
>
|
||||
{entry.user?.username ?? t("common.unknown")}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProviderIcon
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{AUTH_PROVIDER_LABELS[provider]}
|
||||
</span>
|
||||
{/*
|
||||
v1.4.27 B3 — carrier chip below the auth-provider
|
||||
chip. Only renders when the GeoLite2-ASN lookup
|
||||
found an organisation string for the audit-row's
|
||||
IP; private/loopback IPs and offline-miss rows
|
||||
keep the original single-chip layout. The chip
|
||||
carries the short DACH label ("Telekom",
|
||||
"Vodafone", "1&1", "O2"); unknown organisations
|
||||
fall through to the raw GeoLite2 string.
|
||||
*/}
|
||||
{entry.carrier ? (
|
||||
<span
|
||||
className="text-muted-foreground/80 mt-0.5 block text-[10px] leading-tight"
|
||||
data-slot="login-overview-carrier"
|
||||
>
|
||||
{carrierShortLabel(entry.carrier)}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
|
||||
{entry.ipAddress ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{entry.location ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDateTime(entry.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 text-xs"
|
||||
data-testid="login-overview-pagination"
|
||||
>
|
||||
<span>
|
||||
{t("admin.showingEntries", {
|
||||
count: entries.length,
|
||||
@@ -483,7 +519,7 @@ export function LoginOverviewSection() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ export function RecentAuditPreview() {
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Loader2
|
||||
className="text-muted-foreground h-4 w-4 animate-spin"
|
||||
className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -132,6 +132,8 @@ export function RemindersSection() {
|
||||
<Input
|
||||
id="admin-reminder-late"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
enterKeyHint="next"
|
||||
min={15}
|
||||
max={480}
|
||||
value={reminderLateDraft ?? settings?.reminderLateMinutes ?? 120}
|
||||
@@ -153,6 +155,8 @@ export function RemindersSection() {
|
||||
<Input
|
||||
id="admin-reminder-missed"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
enterKeyHint="done"
|
||||
min={30}
|
||||
max={720}
|
||||
value={
|
||||
@@ -177,7 +181,7 @@ export function RemindersSection() {
|
||||
disabled={testNotification.isPending}
|
||||
>
|
||||
{testNotification.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
<Bell className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.notificationTestSend")}
|
||||
@@ -189,7 +193,7 @@ export function RemindersSection() {
|
||||
disabled={reminderCheck.isPending}
|
||||
>
|
||||
{reminderCheck.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
<Activity className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.reminderCheckRun")}
|
||||
@@ -220,7 +224,7 @@ export function RemindersSection() {
|
||||
}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -13,15 +13,36 @@ import {
|
||||
Globe,
|
||||
Key,
|
||||
Loader2,
|
||||
Map,
|
||||
RotateCw,
|
||||
Server,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, useSystemStatus } from "./_shared";
|
||||
|
||||
interface VersionResponse {
|
||||
version: string;
|
||||
buildSha: string | null;
|
||||
builtAt: string | null;
|
||||
offlineGeoEnabled?: boolean;
|
||||
}
|
||||
|
||||
function useOfflineGeoState() {
|
||||
return useQuery({
|
||||
queryKey: ["public", "version"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/version");
|
||||
if (!res.ok) throw new Error("Failed to load version");
|
||||
return (await res.json()).data as VersionResponse;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.16 phase B3: Recharts is ~108 KiB Brotli — defer-load the host
|
||||
// metrics chart so the system-status page only ships it when an admin
|
||||
// actually opens this view. Splitting at the wrapper boundary (instead
|
||||
@@ -44,6 +65,7 @@ export function SystemStatusSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data: status, isError, refetch, isFetching } = useSystemStatus();
|
||||
const { data: version } = useOfflineGeoState();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -163,6 +185,25 @@ export function SystemStatusSection() {
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
)}
|
||||
{/* v1.4.27 R5 — offline GeoLite2 availability. Renders only
|
||||
when /api/version returns the new field so legacy
|
||||
responses do not produce a placeholder row. */}
|
||||
{version?.offlineGeoEnabled !== undefined && (
|
||||
<StatusItem
|
||||
icon={Map}
|
||||
label={t("admin.offlineGeoLabel")}
|
||||
value={
|
||||
version.offlineGeoEnabled
|
||||
? t("admin.offlineGeoEnabled")
|
||||
: t("admin.offlineGeoFallback")
|
||||
}
|
||||
className={
|
||||
version.offlineGeoEnabled
|
||||
? "text-dracula-green"
|
||||
: "text-dracula-yellow"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
// P19: surface load failures inline instead of leaving the
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Cog,
|
||||
Database,
|
||||
GitCommit,
|
||||
Globe,
|
||||
Loader2,
|
||||
Server,
|
||||
Tag,
|
||||
@@ -30,6 +31,7 @@ interface VersionResponse {
|
||||
version: string;
|
||||
buildSha: string | null;
|
||||
builtAt: string | null;
|
||||
offlineGeoEnabled?: boolean;
|
||||
}
|
||||
|
||||
function useVersion() {
|
||||
@@ -116,6 +118,26 @@ export function SystemStatusSummary() {
|
||||
value={formatDateTime(version.builtAt)}
|
||||
/>
|
||||
)}
|
||||
{/* v1.4.27 R5 — surface the offline-geo state so the maintainer
|
||||
spots the missing MAXMIND_LICENSE_KEY without crawling logs.
|
||||
The field is undefined on legacy responses; the row only
|
||||
renders when /api/version answers the new shape. */}
|
||||
{version?.offlineGeoEnabled !== undefined && (
|
||||
<StatusItem
|
||||
icon={Globe}
|
||||
label={t("admin.overview.snapshotOfflineGeo")}
|
||||
value={
|
||||
version.offlineGeoEnabled
|
||||
? t("admin.overview.snapshotOfflineGeoOn")
|
||||
: t("admin.overview.snapshotOfflineGeoOff")
|
||||
}
|
||||
className={
|
||||
version.offlineGeoEnabled
|
||||
? "text-dracula-green"
|
||||
: "text-dracula-yellow"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div
|
||||
@@ -127,7 +149,7 @@ export function SystemStatusSummary() {
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2
|
||||
className="text-muted-foreground h-4 w-4 animate-spin"
|
||||
className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -149,7 +149,7 @@ export function UmamiSection() {
|
||||
disabled={testUmami.isPending || updateSettings.isPending}
|
||||
>
|
||||
{testUmami.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.test")}
|
||||
</Button>
|
||||
@@ -159,7 +159,7 @@ export function UmamiSection() {
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -407,7 +407,7 @@ export function UserManagementSection() {
|
||||
)
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.loadingUsers")}
|
||||
</span>
|
||||
@@ -417,7 +417,7 @@ export function UserManagementSection() {
|
||||
{/* Edit Dialog */}
|
||||
{editingUser && (
|
||||
<div className="bg-muted/80 mt-4 rounded-lg p-4">
|
||||
<h3 className="mb-3 text-sm font-medium">
|
||||
<h3 className="mb-3 text-sm font-semibold">
|
||||
{t("admin.editUserTitle", { name: editingUser.username })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
@@ -460,7 +460,7 @@ export function UserManagementSection() {
|
||||
}
|
||||
>
|
||||
{updateUser.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
@@ -484,7 +484,7 @@ export function UserManagementSection() {
|
||||
{/* Password Reset Dialog */}
|
||||
{resetUser && (
|
||||
<div className="bg-muted/80 mt-4 rounded-lg p-4">
|
||||
<h3 className="mb-3 text-sm font-medium">
|
||||
<h3 className="mb-3 text-sm font-semibold">
|
||||
{t("admin.resetPasswordTitle", { name: resetUser.username })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
@@ -510,7 +510,7 @@ export function UserManagementSection() {
|
||||
}
|
||||
>
|
||||
{resetPw.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.reset")}
|
||||
</Button>
|
||||
@@ -562,7 +562,7 @@ export function UserManagementSection() {
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
{forceLogout.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("admin.section.users.forceLogout")}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -135,7 +135,7 @@ export function WebPushVapidSection() {
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user