diff --git a/src/components/layouts/MfaEnforcementLayout.tsx b/src/components/layouts/MfaEnforcementLayout.tsx
index 23b09db..6b4d260 100644
--- a/src/components/layouts/MfaEnforcementLayout.tsx
+++ b/src/components/layouts/MfaEnforcementLayout.tsx
@@ -7,6 +7,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { AddPasskeyWizard } from '@/components/security/AddPasskeyWizard';
import { TotpEnrollmentWizard } from '@/components/security/TotpEnrollmentWizard';
import { api } from '@/lib/api';
+import { formatDate } from '@/lib/date';
export default function MfaEnforcementLayout() {
const navigate = useNavigate();
@@ -124,7 +125,7 @@ export default function MfaEnforcementLayout() {
{mfaCompliance?.deadline_at && (
- Deadline: {new Date(mfaCompliance.deadline_at).toLocaleDateString()}
+ Deadline: {formatDate(mfaCompliance.deadline_at)}
)}
diff --git a/src/lib/date.ts b/src/lib/date.ts
new file mode 100644
index 0000000..30717bb
--- /dev/null
+++ b/src/lib/date.ts
@@ -0,0 +1,108 @@
+/**
+ * Date/time formatting utilities.
+ *
+ * All timestamps from the API are stored in UTC (ISO 8601, e.g.
+ * "2026-03-06T14:30:00Z" or "2026-03-06T14:30:00.000Z"). This module
+ * provides helpers that always parse those strings as UTC and then render
+ * them in the *user's local timezone* as reported by the browser.
+ *
+ * Usage
+ * -----
+ * import { formatDateTime, formatDate, formatRelative } from "@/lib/date";
+ *
+ * formatDateTime("2026-03-06T14:30:00Z")
+ * // → "Mar 6, 2026, 2:30:00 PM" (user's local tz)
+ *
+ * formatDate("2026-03-06T14:30:00Z")
+ * // → "Mar 6, 2026"
+ *
+ * formatRelative("2026-03-06T14:30:00Z")
+ * // → "5 minutes ago" (via Intl.RelativeTimeFormat)
+ */
+
+/**
+ * Ensure the raw API string is treated as UTC.
+ * The backend BaseModel serialises with a trailing "Z", but we handle
+ * the "+00:00" form and bare ISO strings (YYYY-MM-DDTHH:MM:SS) too.
+ */
+function toUtcDate(raw: string | null | undefined): Date | null {
+ if (!raw) return null;
+ // Already ends with Z or has an offset → parse directly
+ if (raw.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(raw)) {
+ return new Date(raw);
+ }
+ // Bare ISO string without timezone → treat as UTC
+ return new Date(raw + "Z");
+}
+
+/**
+ * Format a UTC timestamp as a human-readable date + time in the user's
+ * local timezone.
+ *
+ * @param raw - ISO 8601 UTC string from the API
+ * @param opts - Optional Intl.DateTimeFormatOptions overrides
+ */
+export function formatDateTime(
+ raw: string | null | undefined,
+ opts?: Intl.DateTimeFormatOptions
+): string {
+ const d = toUtcDate(raw);
+ if (!d || isNaN(d.getTime())) return "—";
+ return new Intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ ...opts,
+ }).format(d);
+}
+
+/**
+ * Format a UTC timestamp as a date-only string in the user's local timezone.
+ */
+export function formatDate(
+ raw: string | null | undefined,
+ opts?: Intl.DateTimeFormatOptions
+): string {
+ const d = toUtcDate(raw);
+ if (!d || isNaN(d.getTime())) return "—";
+ return new Intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ ...opts,
+ }).format(d);
+}
+
+/**
+ * Format a UTC timestamp as a concise time-ago string.
+ * Falls back to formatDateTime for dates older than 30 days.
+ */
+export function formatRelative(raw: string | null | undefined): string {
+ const d = toUtcDate(raw);
+ if (!d || isNaN(d.getTime())) return "—";
+
+ const diffMs = d.getTime() - Date.now();
+ const diffSec = Math.round(diffMs / 1000);
+ const absSec = Math.abs(diffSec);
+
+ const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
+
+ if (absSec < 60) return rtf.format(diffSec, "second");
+ if (absSec < 3600) return rtf.format(Math.round(diffSec / 60), "minute");
+ if (absSec < 86400) return rtf.format(Math.round(diffSec / 3600), "hour");
+ if (absSec < 86400 * 30) return rtf.format(Math.round(diffSec / 86400), "day");
+
+ return formatDateTime(raw);
+}
+
+/**
+ * Return a Date object for a UTC ISO string (or null on invalid input).
+ * Useful when you need to compare timestamps.
+ */
+export function parseUtcDate(raw: string | null | undefined): Date | null {
+ return toUtcDate(raw);
+}
diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx
index c378830..efa17e5 100644
--- a/src/pages/admin/AdminUsersPage.tsx
+++ b/src/pages/admin/AdminUsersPage.tsx
@@ -61,7 +61,8 @@ import { useAuth } from "@/contexts/AuthContext";
function formatDate(d: string | null) {
if (!d) return "—";
- return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
+ const raw = !(d.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(d)) ? d + "Z" : d;
+ return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(new Date(raw));
}
function capitalize(s: string) {
diff --git a/src/pages/org/CompliancePage.tsx b/src/pages/org/CompliancePage.tsx
index e9b069b..dbc9e21 100644
--- a/src/pages/org/CompliancePage.tsx
+++ b/src/pages/org/CompliancePage.tsx
@@ -10,6 +10,7 @@ import { api, OrgComplianceMember, create403Handler } from "@/lib/api";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { useOrg } from "@/contexts/OrgContext";
+import { formatDate } from "@/lib/date";
const STATUS_CONFIG: Record = {
compliant: {
@@ -231,7 +232,7 @@ export default function CompliancePage() {
{member.deadline_at && member.status !== 'compliant' && member.status !== 'not_applicable' && (
Deadline:
- {new Date(member.deadline_at).toLocaleDateString()}
+ {formatDate(member.deadline_at)}
)}
diff --git a/src/pages/org/MembersPage.tsx b/src/pages/org/MembersPage.tsx
index 726bd37..948286e 100644
--- a/src/pages/org/MembersPage.tsx
+++ b/src/pages/org/MembersPage.tsx
@@ -84,11 +84,8 @@ const getInitials = (name: string | null | undefined): string => {
function formatDate(d: string | null | undefined) {
if (!d) return "—";
- return new Date(d).toLocaleDateString(undefined, {
- year: "numeric",
- month: "short",
- day: "numeric",
- });
+ const raw = typeof d === "string" && !(d.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(d)) ? d + "Z" : d;
+ return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(new Date(raw));
}
function capitalize(s: string) {
diff --git a/src/pages/user/ActivityPage.tsx b/src/pages/user/ActivityPage.tsx
index 09705cd..c9e2104 100644
--- a/src/pages/user/ActivityPage.tsx
+++ b/src/pages/user/ActivityPage.tsx
@@ -7,6 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api, AuditLogEntry } from "@/lib/api";
import { useAuth } from "@/contexts/AuthContext";
+import { formatDateTime } from "@/lib/date";
// Map audit log action strings to display info
const getEventDisplay = (action: string) => {
@@ -56,15 +57,7 @@ export default function ActivityPage() {
useEffect(() => { loadEvents(); }, [view]); // eslint-disable-line react-hooks/exhaustive-deps
- const formatDate = (dateString: string) => {
- const date = new Date(dateString);
- return new Intl.DateTimeFormat("en-US", {
- month: "short",
- day: "numeric",
- hour: "numeric",
- minute: "2-digit",
- }).format(date);
- };
+ const formatDate = (dateString: string) => formatDateTime(dateString, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
const filteredEvents = events.filter((e) => {
if (filter === "all") return true;
diff --git a/src/pages/user/SSHKeysPage.tsx b/src/pages/user/SSHKeysPage.tsx
index 2c4aa01..d7ffc57 100644
--- a/src/pages/user/SSHKeysPage.tsx
+++ b/src/pages/user/SSHKeysPage.tsx
@@ -48,6 +48,7 @@ import {
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg, DeptCertPolicy } from "@/lib/api";
+import { formatDate as _formatDate } from "@/lib/date";
// ──────────────────────────────────────────────────────────────────────────────
// Helpers
@@ -55,11 +56,7 @@ import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg
function formatDate(dateStr: string | null): string {
if (!dateStr) return "—";
- return new Date(dateStr).toLocaleDateString(undefined, {
- year: "numeric",
- month: "short",
- day: "numeric",
- });
+ return _formatDate(dateStr);
}
function CopyButton({ text }: { text: string }) {
diff --git a/src/pages/user/SecurityPage.tsx b/src/pages/user/SecurityPage.tsx
index eb5a7db..710518d 100644
--- a/src/pages/user/SecurityPage.tsx
+++ b/src/pages/user/SecurityPage.tsx
@@ -246,13 +246,13 @@ export default function SecurityPage() {
const formatLastUsed = (date: string | null) => {
if (!date) return "Never";
- const d = new Date(date);
+ const d = new Date(date.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(date) ? date : date + "Z");
const now = new Date();
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Yesterday";
if (diffDays < 7) return `${diffDays} days ago`;
- return d.toLocaleDateString();
+ return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(d);
};
return (