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 (