diff --git a/server/private/routers/certificates/getBatchedCertificates.ts b/server/private/routers/certificates/getBatchedCertificates.ts index 7149de036..1c390b7dd 100644 --- a/server/private/routers/certificates/getBatchedCertificates.ts +++ b/server/private/routers/certificates/getBatchedCertificates.ts @@ -46,7 +46,7 @@ const getCertificateQuerySchema = z.object({ async function query(orgId: string, domainList: string[]) { // Try to get CNAME certificates first - let existingCertificates = await db + const existingCertificates = await db .select({ certId: certificates.certId, domain: certificates.domain, @@ -73,16 +73,19 @@ async function query(orgId: string, domainList: string[]) { .where(and(inArray(certificates.domain, domainList))); // All non resolved domain certificates might be `ns` or `wildcard`, - // which means exact domain certificates do not - const nonAvailableCertificates = existingCertificates - .filter((cert) => !domainList.includes(cert.domain)) - .map((cert) => cert.domain); + // which means exact domain certificates do not exist + const foundDomains = new Set( + existingCertificates.map((cert) => cert.domain) + ); + const domainsWithMissingCertificates = domainList.filter( + (domain) => !foundDomains.has(domain) + ); - if (nonAvailableCertificates.length > 0) { + if (domainsWithMissingCertificates.length > 0) { const domainLevelDownSet = new Set(); const wildcardDomainSet = new Set(); - for (const domain of nonAvailableCertificates) { + for (const domain of domainsWithMissingCertificates) { const domainLevelDown = domain.split(".").slice(1).join("."); const wildcardPrefixed = `*.${domainLevelDown}`; domainLevelDownSet.add(domainLevelDown); @@ -131,6 +134,7 @@ async function query(orgId: string, domainList: string[]) { for (const domain of domainList) { const domainLevelDown = domain.split(".").slice(1).join("."); const wildcardPrefixed = `*.${domainLevelDown}`; + certificateMap[domain] = existingCertificates.find( (cert) => diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index fb3004921..cc006565f 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -67,12 +67,12 @@ const listUserDevicesSchema = z.strictObject({ }), query: z.string().optional(), sort_by: z - .enum(["megabytesIn", "megabytesOut"]) + .enum(["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["megabytesIn", "megabytesOut"], + enum: ["megabytesIn", "megabytesOut", "firstSeen", "lastSeen"], description: "Field to sort by" }), order: z @@ -183,7 +183,9 @@ function queryUserDevicesBase() { fingerprintArch: currentFingerprint.arch, fingerprintSerialNumber: currentFingerprint.serialNumber, fingerprintUsername: currentFingerprint.username, - fingerprintHostname: currentFingerprint.hostname + fingerprintHostname: currentFingerprint.hostname, + firstSeen: currentFingerprint.firstSeen, + lastSeen: currentFingerprint.lastSeen }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) @@ -389,14 +391,23 @@ export async function listUserDevices( const countQuery = db.$count(baseQuery.as("filtered_clients")); + const sortColumn = + sort_by === "firstSeen" + ? currentFingerprint.firstSeen + : sort_by === "lastSeen" + ? currentFingerprint.lastSeen + : sort_by + ? clients[sort_by] + : undefined; + const listDevicesQuery = baseQuery .limit(pageSize) .offset(pageSize * (page - 1)) .orderBy( - sort_by + sortColumn ? order === "asc" - ? asc(clients[sort_by]) - : desc(clients[sort_by]) + ? asc(sortColumn) + : desc(sortColumn) : asc(clients.clientId) ); diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index 880019177..5cc904fee 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -104,7 +104,9 @@ export default async function ClientsPage(props: ClientsPageProps) { archived: Boolean(client.archived), blocked: Boolean(client.blocked), approvalState: client.approvalState, - fingerprint + fingerprint, + firstSeen: client.firstSeen ?? null, + lastSeen: client.lastSeen ?? null }; }; diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 92097512c..31a626281 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -91,6 +91,7 @@ export default async function Page(props: { let loginIdps: LoginFormIDP[] = []; let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null = null; + if (!useSmartLogin) { // Load IdPs for DashboardLoginForm (OSS or org-only IdP mode) if (build === "oss" || env.app.identityProviderMode !== "org") { @@ -117,12 +118,12 @@ export default async function Page(props: { `/idp/${persistedData.idpId}` ); - const idp = idpRes.data.data.idp; + const res = idpRes.data.data; lastUsedIdpForSmartLogin = { - idpId: idp.idpId, - name: idp.name, - variant: idp.type, + idpId: res.idp.idpId, + name: res.idp.name, + variant: res.idpOidcConfig?.variant ?? res.idp.type, orgId: persistedData.orgId, lastUsed: true }; diff --git a/src/components/IdpLoginButtons.tsx b/src/components/IdpLoginButtons.tsx index 0851a0dd0..0adccc9c6 100644 --- a/src/components/IdpLoginButtons.tsx +++ b/src/components/IdpLoginButtons.tsx @@ -114,59 +114,52 @@ export default function IdpLoginButtons({
{params.get("gotoapp") ? ( - <> - - + ) : ( - <> - {idps.map((idp) => { - const effectiveType = - idp.variant || idp.name.toLowerCase(); + idps.map((idp) => { + const effectiveType = + idp.variant || idp.name.toLowerCase(); - return ( -
+ + + {idp.name} + - {idp.lastUsed && ( -
- - {t("idpLastUsed")} - -
- )} -
- ); - })} - + {idp.lastUsed && ( +
+ + {t("idpLastUsed")} + +
+ )} +
+ ); + }) )} diff --git a/src/components/IdpTypeIcon.tsx b/src/components/IdpTypeIcon.tsx index be49f9654..ca81ede4f 100644 --- a/src/components/IdpTypeIcon.tsx +++ b/src/components/IdpTypeIcon.tsx @@ -23,6 +23,8 @@ export default function IdpTypeIcon({ }: Props) { const effectiveType = (variant || type || "").toLowerCase(); + console.log(`[IdpTypeIcon]`, { effectiveType, variant, type }); + let src: string | null = null; let defaultAlt = ""; diff --git a/src/components/OrgIdpTable.tsx b/src/components/OrgIdpTable.tsx index f72bc5341..4fb39f513 100644 --- a/src/components/OrgIdpTable.tsx +++ b/src/components/OrgIdpTable.tsx @@ -1,17 +1,6 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; -import { IdpDataTable } from "@app/components/OrgIdpDataTable"; -import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Credenza, CredenzaBody, @@ -22,37 +11,42 @@ import { CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; +import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner"; +import IdpTypeBadge from "@app/components/IdpTypeBadge"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; +import { IdpDataTable } from "@app/components/OrgIdpDataTable"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; import { - ArrowRight, - ArrowUpDown, - MoreHorizontal -} from "lucide-react"; -import { useMemo, useState } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { toast } from "@app/hooks/useToast"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { useRouter } from "next/navigation"; + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; -import Link from "next/link"; -import { useTranslations } from "next-intl"; -import IdpTypeBadge from "@app/components/IdpTypeBadge"; -import IdpTypeIcon from "@app/components/IdpTypeIcon"; -import { useQuery } from "@tanstack/react-query"; -import { useDebounce } from "use-debounce"; -import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; -import { cn } from "@app/lib/cn"; -import { Badge } from "@app/components/ui/badge"; +import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { toast } from "@app/hooks/useToast"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner"; +import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { useDebounce } from "use-debounce"; export type IdpRow = { idpId: number; @@ -483,15 +477,17 @@ export default function IdpTable({ idps, orgId }: Props) { {group.name}
- {group.sources.map((src) => ( - - {src.orgName} - - ))} + {group.sources.map( + (src) => ( + + {src.orgName} + + ) + )}
diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 6b881f4e9..957df9218 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -77,6 +77,8 @@ export type ClientRow = { username: string | null; hostname: string | null; } | null; + firstSeen: number | null; + lastSeen: number | null; }; type ClientTableProps = { @@ -112,7 +114,9 @@ export default function UserDevicesTable({ const defaultUserColumnVisibility = { subnet: false, - niceId: false + niceId: false, + firstSeen: false, + lastSeen: false }; const refreshData = () => { @@ -621,6 +625,68 @@ export default function UserDevicesTable({ accessorKey: "subnet", friendlyName: t("address"), header: () => {t("address")} + }, + { + accessorKey: "firstSeen", + friendlyName: t("firstSeen"), + header: () => { + const firstSeenOrder = getSortDirection( + "firstSeen", + searchParams + ); + + const Icon = + firstSeenOrder === "asc" + ? ArrowDown01Icon + : firstSeenOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + }, + cell: ({ row }) => { + const firstSeen = row.original.firstSeen; + if (!firstSeen) return "-"; + return new Date(firstSeen * 1000).toLocaleString(); + } + }, + { + accessorKey: "lastSeen", + friendlyName: t("lastSeen"), + header: () => { + const lastSeenOrder = getSortDirection( + "lastSeen", + searchParams + ); + + const Icon = + lastSeenOrder === "asc" + ? ArrowDown01Icon + : lastSeenOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + }, + cell: ({ row }) => { + const lastSeen = row.original.lastSeen; + if (!lastSeen) return "-"; + return new Date(lastSeen * 1000).toLocaleString(); + } } ]; diff --git a/src/hooks/useCertificate.ts b/src/hooks/useCertificate.ts index a71c3ef70..1a4ff46f4 100644 --- a/src/hooks/useCertificate.ts +++ b/src/hooks/useCertificate.ts @@ -111,7 +111,8 @@ export function useCertificate({ let certError: string | null = null; if (restartCert.isError) { certError = "Failed to restart"; - } else if (isError) { + } else if (isError || initialCertValue === null) { + // Null value means failed to get the certificate certError = "Failed"; } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index f51ca0ac0..96024c3a0 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,4 +1,10 @@ +import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; +import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; import { build } from "@server/build"; +import { + StatusHistoryResponse, + type BatchedStatusHistoryResponse +} from "@server/lib/statusHistory"; import type { ListAlertRulesResponse } from "@server/routers/alertRule/types"; import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { @@ -7,6 +13,7 @@ import type { QueryConnectionAuditLogResponse, QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; +import type { GetCertificateResponse } from "@server/routers/certificates/types"; import type { ListClientsResponse, ListUserDevicesResponse @@ -16,15 +23,30 @@ import type { ListDomainsResponse } from "@server/routers/domain"; import type { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; +import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; +import type { + LauncherResource, + ListLauncherGroupsResponse, + ListLauncherLabelsResponse, + ListLauncherResourcesResponse, + ListLauncherScaleResponse, + ListLauncherSitesResponse, + ListLauncherViewsResponse +} from "@server/routers/launcher/types"; +import type { GetResourcePolicyResponse } from "@server/routers/policy"; import type { - GetResourceWhitelistResponse, GetResourcePoliciesResponse, + GetResourceWhitelistResponse, ListResourceNamesResponse, - ListResourcesResponse, ListResourceRolesResponse, ListResourceRulesResponse, + ListResourcesResponse, ListResourceUsersResponse } from "@server/routers/resource"; +import type { GetResourceResponse } from "@server/routers/resource/getResource"; +import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo"; +import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; import type { ListRolesResponse } from "@server/routers/role"; import type { ListSitesResponse } from "@server/routers/site"; import type { @@ -33,6 +55,7 @@ import type { ListSiteResourceRolesResponse, ListSiteResourceUsersResponse } from "@server/routers/siteResource"; +import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource"; import type { ListTargetsResponse } from "@server/routers/target"; import type { ListUsersResponse } from "@server/routers/user"; import type ResponseT from "@server/types/Response"; @@ -42,37 +65,12 @@ import { queryOptions } from "@tanstack/react-query"; import { isAxiosError, type AxiosResponse } from "axios"; -import z, { meta } from "zod"; +import z from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; -import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; -import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; -import { - StatusHistoryResponse, - type BatchedStatusHistoryResponse -} from "@server/lib/statusHistory"; -import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; -import type { GetResourcePolicyResponse } from "@server/routers/policy"; -import type { - ListLauncherGroupsResponse, - ListLauncherLabelsResponse, - ListLauncherResourcesResponse, - ListLauncherScaleResponse, - ListLauncherSitesResponse, - ListLauncherViewsResponse, - LauncherListQuery, - LauncherResource, - LauncherViewConfig -} from "@server/routers/launcher/types"; -import type { GetResourceResponse } from "@server/routers/resource/getResource"; -import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo"; -import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource"; -import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; -import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; -import type { GetCertificateResponse } from "@server/routers/certificates/types"; -export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; +export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; export type ProductUpdate = { link: string | null;