Fix: Localized Dates
This commit is contained in:
@@ -7,6 +7,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
|||||||
import { AddPasskeyWizard } from '@/components/security/AddPasskeyWizard';
|
import { AddPasskeyWizard } from '@/components/security/AddPasskeyWizard';
|
||||||
import { TotpEnrollmentWizard } from '@/components/security/TotpEnrollmentWizard';
|
import { TotpEnrollmentWizard } from '@/components/security/TotpEnrollmentWizard';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { formatDate } from '@/lib/date';
|
||||||
|
|
||||||
export default function MfaEnforcementLayout() {
|
export default function MfaEnforcementLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -124,7 +125,7 @@ export default function MfaEnforcementLayout() {
|
|||||||
{mfaCompliance?.deadline_at && (
|
{mfaCompliance?.deadline_at && (
|
||||||
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-center">
|
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-center">
|
||||||
<p className="text-sm font-medium text-destructive">
|
<p className="text-sm font-medium text-destructive">
|
||||||
Deadline: {new Date(mfaCompliance.deadline_at).toLocaleDateString()}
|
Deadline: {formatDate(mfaCompliance.deadline_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+108
@@ -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);
|
||||||
|
}
|
||||||
@@ -61,7 +61,8 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||||||
|
|
||||||
function formatDate(d: string | null) {
|
function formatDate(d: string | null) {
|
||||||
if (!d) return "—";
|
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) {
|
function capitalize(s: string) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { api, OrgComplianceMember, create403Handler } from "@/lib/api";
|
|||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useOrg } from "@/contexts/OrgContext";
|
import { useOrg } from "@/contexts/OrgContext";
|
||||||
|
import { formatDate } from "@/lib/date";
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof Clock }> = {
|
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof Clock }> = {
|
||||||
compliant: {
|
compliant: {
|
||||||
@@ -231,7 +232,7 @@ export default function CompliancePage() {
|
|||||||
{member.deadline_at && member.status !== 'compliant' && member.status !== 'not_applicable' && (
|
{member.deadline_at && member.status !== 'compliant' && member.status !== 'not_applicable' && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
<span className="hidden md:inline">Deadline: </span>
|
<span className="hidden md:inline">Deadline: </span>
|
||||||
{new Date(member.deadline_at).toLocaleDateString()}
|
{formatDate(member.deadline_at)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -84,11 +84,8 @@ const getInitials = (name: string | null | undefined): string => {
|
|||||||
|
|
||||||
function formatDate(d: string | null | undefined) {
|
function formatDate(d: string | null | undefined) {
|
||||||
if (!d) return "—";
|
if (!d) return "—";
|
||||||
return new Date(d).toLocaleDateString(undefined, {
|
const raw = typeof d === "string" && !(d.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(d)) ? d + "Z" : d;
|
||||||
year: "numeric",
|
return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(new Date(raw));
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function capitalize(s: string) {
|
function capitalize(s: string) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { api, AuditLogEntry } from "@/lib/api";
|
import { api, AuditLogEntry } from "@/lib/api";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { formatDateTime } from "@/lib/date";
|
||||||
|
|
||||||
// Map audit log action strings to display info
|
// Map audit log action strings to display info
|
||||||
const getEventDisplay = (action: string) => {
|
const getEventDisplay = (action: string) => {
|
||||||
@@ -56,15 +57,7 @@ export default function ActivityPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadEvents(); }, [view]); // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(() => { loadEvents(); }, [view]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => formatDateTime(dateString, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
const date = new Date(dateString);
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
}).format(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredEvents = events.filter((e) => {
|
const filteredEvents = events.filter((e) => {
|
||||||
if (filter === "all") return true;
|
if (filter === "all") return true;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg, DeptCertPolicy } from "@/lib/api";
|
import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg, DeptCertPolicy } from "@/lib/api";
|
||||||
|
import { formatDate as _formatDate } from "@/lib/date";
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -55,11 +56,7 @@ import { api, SSHKey, SSHCertificate, ApiError, PrincipalOption, MyPrincipalsOrg
|
|||||||
|
|
||||||
function formatDate(dateStr: string | null): string {
|
function formatDate(dateStr: string | null): string {
|
||||||
if (!dateStr) return "—";
|
if (!dateStr) return "—";
|
||||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
return _formatDate(dateStr);
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CopyButton({ text }: { text: string }) {
|
function CopyButton({ text }: { text: string }) {
|
||||||
|
|||||||
@@ -246,13 +246,13 @@ export default function SecurityPage() {
|
|||||||
|
|
||||||
const formatLastUsed = (date: string | null) => {
|
const formatLastUsed = (date: string | null) => {
|
||||||
if (!date) return "Never";
|
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 now = new Date();
|
||||||
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
|
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
if (diffDays === 0) return "Today";
|
if (diffDays === 0) return "Today";
|
||||||
if (diffDays === 1) return "Yesterday";
|
if (diffDays === 1) return "Yesterday";
|
||||||
if (diffDays < 7) return `${diffDays} days ago`;
|
if (diffDays < 7) return `${diffDays} days ago`;
|
||||||
return d.toLocaleDateString();
|
return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(d);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user