diff --git a/openapi/spec/components/schemas/installKey.yaml b/openapi/spec/components/schemas/installKey.yaml index 2af2b1265e5..edb3a077261 100644 --- a/openapi/spec/components/schemas/installKey.yaml +++ b/openapi/spec/components/schemas/installKey.yaml @@ -118,11 +118,11 @@ properties: created before at-rest encryption. The full key is available via reveal. example: 1e7b0f4b expires_at: - description: The absolute date the key expires. Null means it never expires. + description: When the key expires. Null means it never expires. type: string - format: date + format: date-time nullable: true - example: 2026-08-20 + example: 2026-08-20T00:00:00Z created_at: type: string description: The UTC date when the key was created. diff --git a/openapi/spec/components/schemas/installKeyCreate.yaml b/openapi/spec/components/schemas/installKeyCreate.yaml index 10fd9bf0242..32d51346a79 100644 --- a/openapi/spec/components/schemas/installKeyCreate.yaml +++ b/openapi/spec/components/schemas/installKeyCreate.yaml @@ -49,14 +49,14 @@ properties: minimum: 0 maximum: 86400 example: 3600 - expires_at: - type: string - format: date-time - nullable: true + expires_in: + type: integer + minimum: 1 + maximum: 36500 description: | - Absolute date the key expires (must be in the future). Null or omitted - means the key never expires. - example: 2026-08-20T00:00:00Z + How many days the key should keep working (max 36500 ≈ 100 years). + Omit it for a key that never expires. + example: 30 usage_limit: type: integer description: | diff --git a/openapi/spec/components/schemas/installKeyUpdate.yaml b/openapi/spec/components/schemas/installKeyUpdate.yaml index 16ff6d43080..4c0d260c011 100644 --- a/openapi/spec/components/schemas/installKeyUpdate.yaml +++ b/openapi/spec/components/schemas/installKeyUpdate.yaml @@ -54,14 +54,15 @@ properties: Pause or resume the key. Reversible: set to true to stop the key enrolling devices, false to re-enable it. example: true - expires_at: - type: string - format: date-time + expires_in: + type: integer + minimum: 1 + maximum: 36500 nullable: true description: | - Set a new absolute expiration date (must be in the future). Omit to leave - the current expiry unchanged; null makes the key never expire. - example: 2026-08-20T00:00:00Z + Set a new expiration as days from now (1–36500). Omit to leave the + current expiry unchanged; null makes the key never expire. + example: 30 usage_limit: type: integer minimum: 0 diff --git a/openapi/spec/components/schemas/installKeyWithKey.yaml b/openapi/spec/components/schemas/installKeyWithKey.yaml index 05a5eedd51f..f5198caa26f 100644 --- a/openapi/spec/components/schemas/installKeyWithKey.yaml +++ b/openapi/spec/components/schemas/installKeyWithKey.yaml @@ -51,9 +51,9 @@ properties: example: false expires_at: type: string - format: date + format: date-time nullable: true - example: 2026-08-20 + example: 2026-08-20T00:00:00Z created_at: type: string format: date diff --git a/pkg/api/requests/install-key.go b/pkg/api/requests/install-key.go index 84405b923cf..3bb528a6ae1 100644 --- a/pkg/api/requests/install-key.go +++ b/pkg/api/requests/install-key.go @@ -2,20 +2,19 @@ package requests import ( "encoding/json" - "time" "github.com/shellhub-io/shellhub/pkg/api/query" ) -// OptionalTime carries RFC 7396 (JSON Merge Patch) semantics for a nullable field in a partial -// update: an omitted key leaves the value unchanged (Present is false), an explicit null clears it -// (Present is true, Value is nil), and a timestamp sets it. -type OptionalTime struct { +// OptionalInt carries RFC 7396 (JSON Merge Patch) semantics for a nullable integer field in a +// partial update: an omitted key leaves the value unchanged (Present is false), an explicit null +// clears it (Present is true, Value is nil), and a number sets it. +type OptionalInt struct { Present bool - Value *time.Time + Value *int } -func (o *OptionalTime) UnmarshalJSON(data []byte) error { +func (o *OptionalInt) UnmarshalJSON(data []byte) error { o.Present = true if string(data) == "null" { o.Value = nil @@ -41,9 +40,8 @@ type CreateInstallKey struct { // max 24h) is the deferred-decision token's validity. 0/omitted uses the server default. WebhookTimeout int `json:"webhook_timeout" validate:"omitempty,min=0,max=15"` WebhookCallbackTTL int `json:"webhook_callback_ttl" validate:"omitempty,min=0,max=86400"` - // ExpiresAt is the absolute date the key expires. A null (or omitted) value means the key never - // expires. When set, it must be in the future. - ExpiresAt *time.Time `json:"expires_at"` + // ExpiresIn is how many days from now the key should expire. Nil/omitted means no expiration. + ExpiresIn *int `json:"expires_in" validate:"omitempty,min=1,max=36500"` // UsageLimit caps how many devices may enroll: 1 is single-use (one-off), a higher value is that // many devices, 0 (or omitted) is unlimited. Whether the key is reusable is derived from this. UsageLimit int `json:"usage_limit" validate:"omitempty,min=0"` @@ -83,9 +81,9 @@ type UpdateInstallKey struct { // Disabled toggles the reversible pause. Both true and false are honored, so a disabled key can // be re-enabled (unlike Revoked). Disabled *bool `json:"disabled"` - // ExpiresAt sets a new absolute expiration date (must be in the future). Omitted leaves the - // current expiry unchanged; null makes the key never expire (RFC 7396 semantics). - ExpiresAt OptionalTime `json:"expires_at"` + // ExpiresIn sets a new expiration as days from now. RFC 7396: omitted leaves the current expiry + // unchanged, null clears it (never expires), a positive integer sets it. + ExpiresIn OptionalInt `json:"expires_in"` // UsageLimit sets a new enrollment cap (0 unlimited, 1 single-use, N devices). Nil leaves it // untouched. Reusability is re-derived from it. UsageLimit *int `json:"usage_limit" validate:"omitempty,min=0"` diff --git a/server/api/services/install-key.go b/server/api/services/install-key.go index bbf0b51a08d..b04924e331f 100644 --- a/server/api/services/install-key.go +++ b/server/api/services/install-key.go @@ -29,14 +29,15 @@ const ( installKeyMaxEphemeralTimeout = 10 ) -// validateInstallKeyExpiry rejects an expiration that is not in the future. A nil expiry (never -// expires) is always valid. -func validateInstallKeyExpiry(expiresAt *time.Time) error { - if expiresAt != nil && !expiresAt.After(clock.Now()) { - return NewErrBadRequest(errors.New("expires_at must be a future date")) +// installKeyExpiry converts a relative day count into an absolute timestamp anchored to clock.Now(). +func installKeyExpiry(days *int) *time.Time { + if days == nil { + return nil } - return nil + at := clock.Now().AddDate(0, 0, *days) + + return &at } // normalizeMACs lowercases and trims each MAC and drops blanks, so allowlist matching is @@ -190,10 +191,6 @@ func (s *service) CreateInstallKey(ctx context.Context, req *requests.CreateInst return nil, NewErrNamespaceNotFound(req.TenantID, err) } - if err := validateInstallKeyExpiry(req.ExpiresAt); err != nil { - return nil, err - } - // Default to automatic (the classic auto-accept behavior) when no mode is given. mode := models.InstallKeyMode(req.Mode) if mode == "" { @@ -254,7 +251,7 @@ func (s *service) CreateInstallKey(ctx context.Context, req *requests.CreateInst Ephemeral: req.Ephemeral, EphemeralTimeout: ephemeralTimeout, Tags: req.Tags, - ExpiresAt: req.ExpiresAt, + ExpiresAt: installKeyExpiry(req.ExpiresIn), CreatedBy: req.UserID, KeyEncrypted: encryptedKey, KeyHint: installKeyHint(key), @@ -328,7 +325,7 @@ func (s *service) UpdateInstallKey(ctx context.Context, req *requests.UpdateInst // entirely — devices without a key are rejected). Its other fields (name/lifecycle/limit/tags) are // fixed. Revoke never applies: the legacy key is permanent. if installKey.IsSystem() { - if req.Name != "" || req.Revoked != nil || req.UsageLimit != nil || req.ExpiresAt.Present || req.Tags != nil || req.Ephemeral != nil || req.EphemeralTimeout != nil { + if req.Name != "" || req.Revoked != nil || req.UsageLimit != nil || req.ExpiresIn.Present || req.Tags != nil || req.Ephemeral != nil || req.EphemeralTimeout != nil { return NewErrInstallKeyForbidden() } } else if installKey.Revoked { @@ -431,14 +428,14 @@ func (s *service) UpdateInstallKey(ctx context.Context, req *requests.UpdateInst // RFC 7396 semantics: only touch the expiry when the field was sent, so a revoke or disable that // omits it never wipes the key's lifetime. Present with a nil value clears it (never expires). - if req.ExpiresAt.Present { - if req.ExpiresAt.Value != nil && !req.ExpiresAt.Value.After(clock.Now()) { + if req.ExpiresIn.Present { + if req.ExpiresIn.Value != nil && (*req.ExpiresIn.Value < 1 || *req.ExpiresIn.Value > 36500) { return NewErrInstallKeyInvalidField(map[string]string{ - "expires_at": "must be a future date", + "expires_in": "must be between 1 and 36500", }) } - installKey.ExpiresAt = req.ExpiresAt.Value + installKey.ExpiresAt = installKeyExpiry(req.ExpiresIn.Value) } if err := s.store.InstallKeyUpdate(ctx, installKey); err != nil { //nolint:revive diff --git a/server/api/services/install-key_test.go b/server/api/services/install-key_test.go index 506e7189f62..8ebc5f23ce7 100644 --- a/server/api/services/install-key_test.go +++ b/server/api/services/install-key_test.go @@ -50,7 +50,7 @@ func TestCreateInstallKey(t *testing.T) { hashedKey := hex.EncodeToString(keySum[:]) future := now.AddDate(0, 0, 30) - past := now.Add(-time.Hour) + days30 := 30 // InstallKeyCreate receives a struct whose KeyEncrypted has a random nonce, so match on the // deterministic fields and assert the ciphertext and hint were populated. @@ -93,13 +93,24 @@ func TestCreateInstallKey(t *testing.T) { expectedErr: NewErrNamespaceNotFound(tenant, errors.New("error")), }, { - description: "fails when the expiration is in the past", - req: &requests.CreateInstallKey{TenantID: tenant, Name: "ci", ExpiresAt: &past}, + description: "creates a key that never expires when expires_in is omitted", + req: &requests.CreateInstallKey{UserID: "000000000000000000000000", TenantID: tenant, Name: "ci"}, requiredMocks: func(ctx context.Context) { storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant). Return(namespace, nil).Once() + uuidMock := uuidmock.NewMockUUID(t) + uuid.DefaultBackend = uuidMock + uuidMock.On("Generate").Return(generated).Once() + storeMock.On("InstallKeyConflicts", ctx, scope.MustBounded(tenant), &models.InstallKeyConflicts{ID: hashedKey, Name: "ci"}). + Return([]string{}, false, nil).Once() + storeMock.On("InstallKeyCreate", ctx, matchCreate(&models.InstallKey{ + ID: hashedKey, Name: "ci", TenantID: tenant, Reusable: true, + CreatedBy: "000000000000000000000000", + })).Return(hashedKey, nil).Once() + storeMock.On("InstallKeyResolve", ctx, mock.Anything, store.InstallKeyIDResolver, hashedKey). + Return(&models.InstallKey{ID: hashedKey, Name: "ci", TenantID: tenant, Reusable: true}, nil).Once() }, - expectedErr: NewErrBadRequest(errors.New("expires_at must be a future date")), + expectedKey: plain, }, { description: "fails when webhook mode has no http(s) url", @@ -209,8 +220,8 @@ func TestCreateInstallKey(t *testing.T) { expectedKey: plain, }, { - description: "stores the provided expiration date", - req: &requests.CreateInstallKey{UserID: "000000000000000000000000", TenantID: tenant, Name: "ci", ExpiresAt: &future}, + description: "converts expires_in days to an absolute expiry", + req: &requests.CreateInstallKey{UserID: "000000000000000000000000", TenantID: tenant, Name: "ci", ExpiresIn: &days30}, requiredMocks: func(ctx context.Context) { storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant). Return(namespace, nil).Once() @@ -287,6 +298,14 @@ func TestUpdateInstallKey(t *testing.T) { storeMock := storemock.NewMockStore(t) queryOptionsMock := storemock.NewMockQueryOptions(t) storeMock.On("Options").Return(queryOptionsMock).Maybe() + + now := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + clockMock := clockmock.NewMockClock(t) + prevClock := clock.DefaultBackend + clock.DefaultBackend = clockMock + defer func() { clock.DefaultBackend = prevClock }() + clockMock.On("Now").Return(now).Maybe() + const tenant = "00000000-0000-4000-0000-000000000000" namespace := &models.Namespace{Name: "namespace", TenantID: tenant} truePtr := true @@ -295,6 +314,9 @@ func TestUpdateInstallKey(t *testing.T) { limitUnlimited := 0 ephemeralTimeout5 := 5 modeAutomatic := "automatic" + days60 := 60 + days0 := 0 + days36501 := 36501 cases := []struct { description string @@ -467,6 +489,74 @@ func TestUpdateInstallKey(t *testing.T) { }, expectedErr: nil, }, + { + description: "sets a new expiry from expires_in days", + req: &requests.UpdateInstallKey{TenantID: tenant, CurrentName: "ci", ExpiresIn: requests.OptionalInt{Present: true, Value: &days60}}, + requiredMocks: func(ctx context.Context) { + storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant). + Return(namespace, nil).Once() + storeMock.On("InstallKeyResolve", ctx, mock.Anything, store.InstallKeyNameResolver, "ci"). + Return(&models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant}, nil).Once() + expiry := now.AddDate(0, 0, 60) + storeMock.On("InstallKeyUpdate", ctx, &models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant, ExpiresAt: &expiry}). + Return(nil).Once() + }, + expectedErr: nil, + }, + { + description: "clears the expiry when expires_in is null", + req: &requests.UpdateInstallKey{TenantID: tenant, CurrentName: "ci", ExpiresIn: requests.OptionalInt{Present: true, Value: nil}}, + requiredMocks: func(ctx context.Context) { + existing := now.AddDate(0, 0, 30) + storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant). + Return(namespace, nil).Once() + storeMock.On("InstallKeyResolve", ctx, mock.Anything, store.InstallKeyNameResolver, "ci"). + Return(&models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant, ExpiresAt: &existing}, nil).Once() + storeMock.On("InstallKeyUpdate", ctx, &models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant, ExpiresAt: nil}). + Return(nil).Once() + }, + expectedErr: nil, + }, + { + description: "leaves the expiry unchanged when expires_in is omitted", + req: &requests.UpdateInstallKey{TenantID: tenant, CurrentName: "ci", Revoked: &truePtr}, + requiredMocks: func(ctx context.Context) { + existing := now.AddDate(0, 0, 30) + storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant). + Return(namespace, nil).Once() + storeMock.On("InstallKeyResolve", ctx, mock.Anything, store.InstallKeyNameResolver, "ci"). + Return(&models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant, ExpiresAt: &existing, Reusable: true}, nil).Once() + storeMock.On("InstallKeyUpdate", ctx, &models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant, ExpiresAt: &existing, Reusable: true, Revoked: true}). + Return(nil).Once() + }, + expectedErr: nil, + }, + { + description: "rejects expires_in below 1", + req: &requests.UpdateInstallKey{TenantID: tenant, CurrentName: "ci", ExpiresIn: requests.OptionalInt{Present: true, Value: &days0}}, + requiredMocks: func(ctx context.Context) { + storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant). + Return(namespace, nil).Once() + storeMock.On("InstallKeyResolve", ctx, mock.Anything, store.InstallKeyNameResolver, "ci"). + Return(&models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant}, nil).Once() + }, + expectedErr: NewErrInstallKeyInvalidField(map[string]string{ + "expires_in": "must be between 1 and 36500", + }), + }, + { + description: "rejects expires_in above 36500", + req: &requests.UpdateInstallKey{TenantID: tenant, CurrentName: "ci", ExpiresIn: requests.OptionalInt{Present: true, Value: &days36501}}, + requiredMocks: func(ctx context.Context) { + storeMock.On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, tenant). + Return(namespace, nil).Once() + storeMock.On("InstallKeyResolve", ctx, mock.Anything, store.InstallKeyNameResolver, "ci"). + Return(&models.InstallKey{ID: "hash", Name: "ci", TenantID: tenant}, nil).Once() + }, + expectedErr: NewErrInstallKeyInvalidField(map[string]string{ + "expires_in": "must be between 1 and 36500", + }), + }, { description: "rejects changing ephemeral on the legacy key", req: &requests.UpdateInstallKey{TenantID: tenant, CurrentName: "legacy", Ephemeral: &truePtr}, diff --git a/ui/apps/console/src/pages/install-keys/CreateInstallKeyDrawer.tsx b/ui/apps/console/src/pages/install-keys/CreateInstallKeyDrawer.tsx index 4c80f706245..2da775cbdde 100644 --- a/ui/apps/console/src/pages/install-keys/CreateInstallKeyDrawer.tsx +++ b/ui/apps/console/src/pages/install-keys/CreateInstallKeyDrawer.tsx @@ -15,7 +15,7 @@ import ExpirationField from "./ExpirationField"; import ModeField, { type InstallKeyMode } from "./ModeField"; import UsageLimitField from "./UsageLimitField"; import { - defaultExpiry, + keyExpiryPayload, parseAllowedMacs, validateModeConfig, validateName, @@ -48,7 +48,7 @@ function CreateInstallKeyDrawer({ const [ephemeral, setEphemeral] = useState(false); const [ephemeralTimeout, setEphemeralTimeout] = useState(10); const [tags, setTags] = useState([]); - const [expiresAt, setExpiresAt] = useState(defaultExpiry()); + const [expiresIn, setExpiresIn] = useState("30"); const [submitting, setSubmitting] = useState(false); const [nameError, setNameError] = useState(""); const [error, setError] = useState(""); @@ -74,7 +74,7 @@ function CreateInstallKeyDrawer({ setEphemeral(false); setEphemeralTimeout(10); setTags([]); - setExpiresAt(defaultExpiry()); + setExpiresIn("30"); setNameError(""); setError(""); setGeneratedKey(""); @@ -117,7 +117,7 @@ function CreateInstallKeyDrawer({ } : {}), ...(mode === "allowlist" ? { allowed_macs: macList } : {}), - expires_at: expiresAt, + ...keyExpiryPayload(expiresIn), // Reusability is derived server-side from this: 1 is single-use, // N (>=2) enrolls N devices, 0 is unlimited (reusable forever). usage_limit: usageLimit, @@ -249,7 +249,10 @@ function CreateInstallKeyDrawer({ webhookCallbackTtl={webhookCallbackTtl} onWebhookCallbackTtlChange={setWebhookCallbackTtl} /> - + (null); + const [expiresIn, setExpiresIn] = useState("-1"); + const [expiryTouched, setExpiryTouched] = useState(false); + const [isExpired, setIsExpired] = useState(false); const [tags, setTags] = useState([]); const [submitting, setSubmitting] = useState(false); const [nameError, setNameError] = useState(""); @@ -76,12 +82,21 @@ function EditInstallKeyDrawer({ setUsageLimit(installKey?.usage_limit ?? 1); setEphemeral(installKey?.ephemeral ?? false); setEphemeralTimeout(installKey?.ephemeral_timeout || 10); - setExpiresAt(installKey?.expires_at ?? null); + const { days, expired } = getRemainingDays(installKey?.expires_at); + setExpiresIn(days); + setExpiryTouched(false); + setIsExpired(expired); setTags(installKey?.tags ?? []); setNameError(""); setError(""); }); + const handleExpiresInChange = (value: string) => { + setExpiresIn(value); + setExpiryTouched(true); + setIsExpired(false); + }; + const handleNameChange = (value: string) => { setName(value); if (nameError) setNameError(validateName(value.trim())); @@ -135,7 +150,7 @@ function EditInstallKeyDrawer({ ...modeBody, name: name.trim(), usage_limit: usageLimit, - expires_at: expiresAt, + ...(expiryTouched ? keyExpiryUpdatePayload(expiresIn) : {}), tags, ephemeral, // Only meaningful for ephemeral keys; already clamped to 1-10 by the field. @@ -223,7 +238,18 @@ function EditInstallKeyDrawer({ /> {!isSystem && ( <> - +
+ {isExpired && ( + + This key has expired. Set a new expiration to resume + registrations. + + )} + +
{usageLimitError && ( diff --git a/ui/apps/console/src/pages/install-keys/ExpirationField.tsx b/ui/apps/console/src/pages/install-keys/ExpirationField.tsx index d33c7092907..8d4b2c90f04 100644 --- a/ui/apps/console/src/pages/install-keys/ExpirationField.tsx +++ b/ui/apps/console/src/pages/install-keys/ExpirationField.tsx @@ -4,13 +4,12 @@ import { CheckIcon, ChevronDownIcon, } from "@heroicons/react/24/outline"; -import { addDays, startOfMonth } from "date-fns"; +import { addDays, differenceInCalendarDays, startOfMonth } from "date-fns"; import { DayPicker } from "react-day-picker"; import { Dropdown } from "@shellhub/design-system/primitives"; import { cn } from "@shellhub/design-system/cn"; import { formatDateShort } from "@/utils/date"; import { LABEL } from "@/utils/styles"; -import { defaultExpiry, startOfDayUtc } from "./helpers"; const PRESETS = [ { label: "30 days", days: 30 }, @@ -31,13 +30,13 @@ const CALENDAR_CLASSNAMES = { month_caption: "flex items-center justify-center h-9", caption_label: "text-sm font-semibold text-text-primary", month_grid: "w-full border-collapse", - weekdays: "flex", + weekdays: "flex w-full", weekday: - "w-9 h-8 flex items-center justify-center text-2xs font-mono font-medium uppercase tracking-label text-text-muted", + "flex-1 h-8 flex items-center justify-center text-2xs font-mono font-medium uppercase tracking-label text-text-muted", week: "flex w-full", - day: "p-0", + day: "flex-1 p-0", day_button: - "inline-flex items-center justify-center w-9 h-9 rounded-md text-xs text-text-secondary hover:bg-hover-subtle hover:text-text-primary transition-colors cursor-pointer", + "inline-flex items-center justify-center w-full h-9 rounded-md text-xs text-text-secondary hover:bg-hover-subtle hover:text-text-primary transition-colors cursor-pointer", today: "[&>button]:text-primary [&>button]:font-semibold", selected: "[&>button]:bg-primary [&>button]:text-white [&>button]:font-semibold [&>button]:hover:bg-primary [&>button]:hover:text-white", @@ -48,21 +47,21 @@ const CALENDAR_CLASSNAMES = { }; export default function ExpirationField({ - value, - onChange, + expiresIn, + onExpiresInChange, }: { - value: string | null; - onChange: (value: string | null) => void; + expiresIn: string; + onExpiresInChange: (value: string) => void; }) { const [open, setOpen] = useState(false); - const never = value === null; - const selected = value ? new Date(value) : undefined; - const selectedDay = value ? value.slice(0, 10) : null; + const days = Number(expiresIn); + const never = days < 1; + const targetDate = never ? undefined : addDays(new Date(), days); const tomorrow = addDays(new Date(), 1); - const pick = (iso: string | null) => { - onChange(iso); + const pick = (value: number | null) => { + onExpiresInChange(value === null ? "-1" : String(value)); setOpen(false); }; @@ -77,7 +76,9 @@ export default function ExpirationField({ > - {never ? "Never expires" : formatDateShort(value ?? "")} + {never + ? "Never expires" + : formatDateShort(targetDate!.toISOString())} - +
{PRESETS.map((preset) => { - const iso = startOfDayUtc(addDays(new Date(), preset.days)); - const active = selectedDay === iso.slice(0, 10); + const active = days === preset.days; return (
-

- {never - ? "The key never expires." - : "The key expires at the start of the chosen day."} -

); } diff --git a/ui/apps/console/src/pages/install-keys/helpers.ts b/ui/apps/console/src/pages/install-keys/helpers.ts index 814ef44d550..0c5bf77a75d 100644 --- a/ui/apps/console/src/pages/install-keys/helpers.ts +++ b/ui/apps/console/src/pages/install-keys/helpers.ts @@ -1,16 +1,6 @@ -import { addDays, differenceInCalendarDays, format } from "date-fns"; +import { differenceInCalendarDays, format } from "date-fns"; import { type InstallKey } from "@/client"; -/** Midnight-UTC RFC3339 string for a date, so it round-trips through a day input. */ -export function startOfDayUtc(date: Date): string { - return new Date(`${date.toISOString().slice(0, 10)}T00:00:00Z`).toISOString(); -} - -/** The prefilled expiry for a new/never-off key: 30 days out at the start of that day. */ -export function defaultExpiry(): string { - return startOfDayUtc(addDays(new Date(), 30)); -} - /** * The auto-managed system keys: every namespace has two, discriminated by `type` — `legacy` (devices * enrolled with only a tenant ID) and `pairing` (devices accepted through the pairing-code flow). A @@ -207,6 +197,29 @@ export function getInstallKeyStateLabel( } } +export function keyExpiryPayload(expiresIn: string): { expires_in?: number } { + const days = Number(expiresIn); + return days > 0 ? { expires_in: days } : {}; +} + +export function keyExpiryUpdatePayload(expiresIn: string): { + expires_in: number | null; +} { + const days = Number(expiresIn); + return { expires_in: days > 0 ? days : null }; +} + +export function getRemainingDays(expiresAt: string | null | undefined): { + days: string; + expired: boolean; +} { + if (expiresAt == null) return { days: "-1", expired: false }; + const expired = new Date(expiresAt).getTime() <= Date.now(); + const remaining = differenceInCalendarDays(new Date(expiresAt), new Date()); + if (remaining < 1) return { days: "1", expired }; + return { days: String(remaining), expired: false }; +} + export type ExpiryTone = "muted" | "normal" | "warning" | "danger"; export interface ExpiryInfo {