From 5e7afd00aab6e8162e990a823b7e0825c5b05891 Mon Sep 17 00:00:00 2001 From: Javier Marcos <1271349+javuto@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:35:57 +0200 Subject: [PATCH] Fixes for enrollment packages expiration time --- .../src/features/enrollment/EnrollPage.tsx | 137 +++++++++++++++++- frontend/src/lib/time.ts | 44 +++++- 2 files changed, 175 insertions(+), 6 deletions(-) diff --git a/frontend/src/features/enrollment/EnrollPage.tsx b/frontend/src/features/enrollment/EnrollPage.tsx index 6a0aad4c..dbaa4cfe 100644 --- a/frontend/src/features/enrollment/EnrollPage.tsx +++ b/frontend/src/features/enrollment/EnrollPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { usePageTitle } from '$/lib/usePageTitle'; import { useParams, useNavigate } from '@tanstack/react-router'; import { useQuery, useMutation, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; @@ -23,7 +23,7 @@ import { AuthError, ApiError } from '$/api/client'; import { Button } from '$/components/atoms/Button'; import { Skeleton } from '$/components/data/Skeleton'; import { cn } from '$/lib/cn'; -import { formatRelative } from '$/lib/time'; +import { formatRelative, formatTimeUntil } from '$/lib/time'; import { CertificateCard } from './CertificateCard'; import { FlagsCard } from './FlagsCard'; import { AssembledConfigCard } from './AssembledConfigCard'; @@ -331,6 +331,7 @@ export function EnrollPage() { isPending={removeMut.isPending} /> + )} @@ -387,6 +388,133 @@ function PageTabButton({ ); } +// --------------------------------------------------------------------------- +// SecretField — read-only secret display with copy + download buttons. +// --------------------------------------------------------------------------- +function SecretField({ + envName, + envDisplayName, +}: { + envName: string; + envDisplayName: string; +}) { + const [secret, setSecret] = useState(''); + const [revealed, setRevealed] = useState(false); + const [copied, setCopied] = useState(false); + const [err, setErr] = useState(null); + + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: ['enroll-secret', envName], + queryFn: () => getEnrollData(envName, 'secret'), + staleTime: 0, + }); + + useEffect(() => { + if (data?.data) { + setSecret(data.data); + } + }, [data]); + + async function handleCopy() { + if (!secret) return; + try { + await navigator.clipboard.writeText(secret); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + setErr('Copy failed — your browser blocked clipboard access.'); + } + } + + function handleDownload() { + if (!secret) return; + const blob = new Blob([secret], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `osctrl-${envDisplayName}.secret`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + return ( + + + + Enroll secret + + + ⓘ + + + + {isLoading && } + + {isError && !isLoading && ( + + + {error instanceof Error ? error.message : 'Failed to load secret'} + + refetch()}>Retry + + )} + + {!isLoading && !isError && ( + + + setRevealed((v) => !v)} + title={revealed ? 'Hide secret' : 'Reveal secret'} + > + {revealed ? 'Hide' : 'Show'} + + + {copied ? 'Copied ✓' : 'Copy'} + + + Download + + + )} + + {err && ( + {err} + )} + + ); +} + // --------------------------------------------------------------------------- // NotAcceptingHint — compact inline alert on the Install tab when enrolls // are closed, with a one-click jump to the Lifecycle tab where Rotate @@ -696,8 +824,9 @@ function LifecycleCard({ if (expireValue.toLowerCase().includes('never')) return 'never expires'; const d = new Date(expireValue); if (isNaN(d.getTime())) return 'never expires'; - // Future date → "expires in "; past date → "expired ago". - return `expires ${formatRelative(expireValue)}`; + const diffMs = d.getTime() - Date.now(); + if (diffMs <= 0) return `expired ${formatRelative(expireValue)} ago`; + return `expires ${formatTimeUntil(expireValue)}`; })(); return ( diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts index 438a8dae..50ce7dfe 100644 --- a/frontend/src/lib/time.ts +++ b/frontend/src/lib/time.ts @@ -28,8 +28,8 @@ export function formatRelative(iso: string): string { const diffMs = Date.now() - d.getTime(); if (diffMs < 0) { - // Future timestamp — treat as just now - return 'just now'; + // Future timestamp — use formatTimeUntil for a proper "in 3d" string. + return formatTimeUntil(iso); } if (diffMs < MINUTE) { @@ -56,6 +56,46 @@ export function formatRelative(iso: string): string { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } +/** + * Returns a compact "time until" string for a future ISO-8601 timestamp. + * Mirrors formatRelative but for future dates. + * + * Examples: + * in 3 seconds → "in 3s" + * in 4 minutes → "in 4m" + * in 2 hours → "in 2h" + * in 1 day → "in 1d" + * > 7 days → "Mar 14" + * invalid/past → "—" + */ +export function formatTimeUntil(iso: string): string { + if (!iso) return '—'; + + const d = new Date(iso); + if (isNaN(d.getTime())) return '—'; + + const diffMs = d.getTime() - Date.now(); + if (diffMs <= 0) return '—'; + + if (diffMs < MINUTE) { + const s = Math.floor(diffMs / SECOND); + return `in ${s}s`; + } + if (diffMs < HOUR) { + const m = Math.floor(diffMs / MINUTE); + return `in ${m}m`; + } + if (diffMs < DAY) { + const h = Math.floor(diffMs / HOUR); + return `in ${h}h`; + } + if (diffMs < WEEK) { + const day = Math.floor(diffMs / DAY); + return `in ${day}d`; + } + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + /** * Returns a full ISO-8601 timestamp string formatted for display in tooltips. * E.g. "2024-03-14 15:09:26 UTC"
+ {error instanceof Error ? error.message : 'Failed to load secret'} +
{err}