diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field.tsx deleted file mode 100644 index ce53552a21..0000000000 --- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { useCreateIdentity } from "@/app/(app)/apis/[apiId]/_components/create-key/hooks/use-create-identity"; -import { useFetchIdentities } from "@/app/(app)/apis/[apiId]/_components/create-key/hooks/use-fetch-identities"; -import { createIdentityOptions } from "@/app/(app)/apis/[apiId]/_components/create-key/hooks/use-fetch-identities/create-identity-options"; -import { FormCombobox } from "@/components/ui/form-combobox"; -import { TriangleWarning2 } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { cn } from "@unkey/ui/src/lib/utils"; -import { useState } from "react"; - -type ExternalIdFieldProps = { - value: string | null; - onChange: (identityId: string | null, externalId: string | null) => void; - error?: string; - disabled?: boolean; -}; - -export const ExternalIdField = ({ - value, - onChange, - error, - disabled = false, -}: ExternalIdFieldProps) => { - const [searchValue, setSearchValue] = useState(""); - const { identities, isFetchingNextPage, hasNextPage, loadMore } = useFetchIdentities(); - - const createIdentity = useCreateIdentity((data) => { - onChange(data.identityId, data.externalId); - }); - - const handleCreateIdentity = () => { - if (searchValue.trim()) { - createIdentity.mutate({ - externalId: searchValue.trim(), - meta: null, - }); - } - }; - - const exactMatch = identities.some( - (id) => id.externalId.toLowerCase() === searchValue.toLowerCase().trim(), - ); - - const filteredIdentities = searchValue.trim() - ? identities.filter((identity) => - identity.externalId.toLowerCase().includes(searchValue.toLowerCase().trim()), - ) - : identities; - - const hasPartialMatches = filteredIdentities.length > 0; - - const baseOptions = createIdentityOptions({ - identities: filteredIdentities, - hasNextPage, - isFetchingNextPage, - loadMore, - }); - - const createOption = - searchValue.trim() && !exactMatch && hasPartialMatches - ? { - label: ( -
-
- -
- - Create "{searchValue.trim()}" - -
- ), - value: "__create_new__", - selectedLabel: <>, - searchValue: searchValue.trim(), - } - : null; - - const options = createOption ? [createOption, ...baseOptions] : baseOptions; - - return ( - setSearchValue(e.currentTarget.value)} - onSelect={(val) => { - if (val === "__create_new__") { - handleCreateIdentity(); - return; - } - const identity = identities.find((id) => id.id === val); - onChange(identity?.id || null, identity?.externalId || null); - }} - placeholder={ -
- Select External ID -
- } - searchPlaceholder="Search External ID..." - emptyMessage={ - searchValue.trim() && !exactMatch ? ( -
-
-
-
- -
-
- External ID not found -
-
-
-
-
-
-
- You can create a new identity with this{" "} - External ID and connect it{" "} - immediately. -
-
- -
-
- ) : ( -
No results found
- ) - } - variant="default" - error={error} - disabled={disabled} - /> - ); -}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field/index.tsx new file mode 100644 index 0000000000..b8ac28a5d7 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field/index.tsx @@ -0,0 +1,244 @@ +import { useCreateIdentity } from "@/app/(app)/apis/[apiId]/_components/create-key/hooks/use-create-identity"; +import { useFetchIdentities } from "@/app/(app)/apis/[apiId]/_components/create-key/hooks/use-fetch-identities"; +import { createIdentityOptions } from "@/app/(app)/apis/[apiId]/_components/create-key/hooks/use-fetch-identities/create-identity-options"; +import { FormCombobox } from "@/components/ui/form-combobox"; +import type { Identity } from "@unkey/db"; +import { TriangleWarning2 } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useMemo, useState } from "react"; +import { useSearchIdentities } from "./use-search-identities"; + +type ExternalIdFieldProps = { + value: string | null; + onChange: (identityId: string | null, externalId: string | null) => void; + error?: string; + disabled?: boolean; + currentIdentity?: { + id: string; + externalId: string; + meta?: Identity["meta"]; + }; +}; + +export const ExternalIdField = ({ + value, + onChange, + error, + disabled = false, + currentIdentity, +}: ExternalIdFieldProps) => { + const [searchValue, setSearchValue] = useState(""); + + const trimmedSearchValue = searchValue.trim(); + + const { identities, isFetchingNextPage, hasNextPage, loadMore, isLoading } = useFetchIdentities(); + const { searchResults, isSearching } = useSearchIdentities(searchValue); + + const createIdentity = useCreateIdentity((data) => { + onChange(data.identityId, data.externalId); + }); + + // Combine loaded identities with search results, prioritizing search when available + const allIdentities = useMemo(() => { + if (trimmedSearchValue && searchResults.length > 0) { + // When searching, use search results + return searchResults; + } + if (trimmedSearchValue && searchResults.length === 0 && !isSearching) { + // No search results found, filter from loaded identities as fallback + const searchTerm = trimmedSearchValue.toLowerCase(); + return identities.filter((identity) => + identity.externalId.toLowerCase().includes(searchTerm), + ); + } + // No search query, use all loaded identities + return identities; + }, [identities, searchResults, trimmedSearchValue, isSearching]); + + // Ensure current identity is always available in the options + const allIdentitiesWithCurrent = useMemo(() => { + if (!currentIdentity || !value) { + return allIdentities; + } + + // Check if current identity is already in the list + const currentExists = allIdentities.some((identity) => identity.id === currentIdentity.id); + + if (currentExists) { + return allIdentities; + } + + return [ + { + id: currentIdentity.id, + externalId: currentIdentity.externalId, + meta: currentIdentity.meta || {}, + workspaceId: "", + environment: "", + createdAt: Date.now(), + updatedAt: Date.now(), + }, + ...allIdentities, + ]; + }, [allIdentities, currentIdentity, value]); + + const handleCreateIdentity = () => { + if (trimmedSearchValue) { + createIdentity.mutate({ + externalId: trimmedSearchValue, + meta: null, + }); + } + }; + + const exactMatch = allIdentitiesWithCurrent.some( + (id) => id.externalId.toLowerCase() === trimmedSearchValue.toLowerCase(), + ); + + const hasPartialMatches = allIdentitiesWithCurrent.length > 0; + + // Don't show load more when actively searching + const showLoadMore = !trimmedSearchValue && hasNextPage; + + const baseOptions = createIdentityOptions({ + identities: allIdentitiesWithCurrent, + hasNextPage: showLoadMore, + isFetchingNextPage, + loadMore, + }); + + const createOption = + trimmedSearchValue && !exactMatch && hasPartialMatches && !isSearching + ? { + label: ( +
+
+ +
+ + Create "{trimmedSearchValue}" + +
+ ), + value: "__create_new__", + selectedLabel: <>, + searchValue: trimmedSearchValue, + } + : null; + + const options = createOption ? [createOption, ...baseOptions] : baseOptions; + + const isComboboxLoading = isLoading || (isSearching && trimmedSearchValue.length > 0); + + return ( + { + setSearchValue(e.currentTarget.value); + }} + onSelect={(val) => { + if (val === "__create_new__") { + handleCreateIdentity(); + return; + } + const identity = allIdentitiesWithCurrent.find((id) => id.id === val); + onChange(identity?.id || null, identity?.externalId || null); + }} + placeholder={ +
Select External ID
+ } + searchPlaceholder="Search External ID..." + emptyMessage={ + trimmedSearchValue && !exactMatch ? ( +
+
+
+
+ +
+
+ External ID not found +
+
+
+
+
+
+
+ You can create a new identity with this{" "} + External ID and connect it{" "} + immediately. +
+
+ +
+
+ ) : isComboboxLoading ? ( +
+
+ {isSearching ? "Searching..." : "Loading identities..."} +
+ ) : ( +
+ No results found +
+ ) + } + variant="default" + error={error} + disabled={disabled || isLoading} + loading={isComboboxLoading} + title={ + isComboboxLoading + ? isSearching && trimmedSearchValue + ? "Searching for identities..." + : "Loading available identities..." + : undefined + } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field/use-search-identities.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field/use-search-identities.tsx new file mode 100644 index 0000000000..85dc8109ad --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field/use-search-identities.tsx @@ -0,0 +1,34 @@ +import { trpc } from "@/lib/trpc/client"; +import { useEffect, useMemo, useState } from "react"; + +export const useSearchIdentities = (query: string, debounceMs = 300) => { + const [debouncedQuery, setDebouncedQuery] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(query.trim()); + }, debounceMs); + + return () => clearTimeout(timer); + }, [query, debounceMs]); + + const { data, isLoading, error } = trpc.identity.search.useQuery( + { query: debouncedQuery }, + { + enabled: debouncedQuery.length > 0, + staleTime: 30_000, + }, + ); + + const searchResults = useMemo(() => { + return data?.identities || []; + }, [data?.identities]); + + const isSearching = query.trim() !== debouncedQuery || (debouncedQuery.length > 0 && isLoading); + + return { + searchResults, + isSearching, + searchError: error, + }; +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-create-identity.ts b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-create-identity.ts index 3f9c0c8902..96f241d152 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-create-identity.ts +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-create-identity.ts @@ -14,6 +14,7 @@ export const useCreateIdentity = ( }); trpcUtils.identity.query.invalidate(); + trpcUtils.identity.search.invalidate(); if (onSuccess) { onSuccess(data); diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-fetch-identities/index.ts b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-fetch-identities/index.ts index 650bf167b9..ef9990c3e1 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-fetch-identities/index.ts +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-fetch-identities/index.ts @@ -4,7 +4,8 @@ import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; import { useMemo } from "react"; -export const useFetchIdentities = (limit = 50) => { +const MAX_IDENTITY_FETCH_LIMIT = 10; +export const useFetchIdentities = (limit = MAX_IDENTITY_FETCH_LIMIT) => { const trpcUtils = trpc.useUtils(); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-external-id/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-external-id/index.tsx index 2c2a13a8e8..ef94bee326 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-external-id/index.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-external-id/index.tsx @@ -120,6 +120,14 @@ export const EditExternalId = ({ setSelectedIdentityId(identityId); setSelectedExternalId(externalId); }} + currentIdentity={ + keyDetails.identity_id + ? { + id: keyDetails.identity_id, + externalId: keyDetails.owner_id || "", + } + : undefined + } disabled={updateKeyOwner.isLoading || Boolean(originalIdentityId)} /> diff --git a/apps/dashboard/lib/trpc/routers/identity/query.ts b/apps/dashboard/lib/trpc/routers/identity/query.ts index 203bed01d5..fc11f6b50f 100644 --- a/apps/dashboard/lib/trpc/routers/identity/query.ts +++ b/apps/dashboard/lib/trpc/routers/identity/query.ts @@ -8,7 +8,7 @@ const identitiesQueryPayload = z.object({ limit: z.number().optional().default(50), }); -const IdentityResponseSchema = z.object({ +export const IdentityResponseSchema = z.object({ id: z.string(), externalId: z.string(), workspaceId: z.string(), diff --git a/apps/dashboard/lib/trpc/routers/identity/search.ts b/apps/dashboard/lib/trpc/routers/identity/search.ts new file mode 100644 index 0000000000..9916d3c501 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/identity/search.ts @@ -0,0 +1,73 @@ +import { db } from "@/lib/db"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "../../trpc"; +import { IdentityResponseSchema } from "./query"; + +const LIMIT = 5; + +const SearchIdentitiesResponse = z.object({ + identities: z.array(IdentityResponseSchema), +}); + +export const searchIdentities = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input( + z.object({ + query: z + .string() + .trim() + .min(1, "Search query is required") + .max(255, "Search query is too long"), + }), + ) + .output(SearchIdentitiesResponse) + .query(async ({ ctx, input }) => { + const { query } = input; + const workspaceId = ctx.workspace.id; + + try { + const identitiesQuery = await db.query.identities.findMany({ + where: (identity, { and, eq, like }) => { + return and( + eq(identity.workspaceId, workspaceId), + eq(identity.deleted, false), + like(identity.externalId, `%${query}%`), + ); + }, + limit: LIMIT, + orderBy: (identities, { asc }) => [asc(identities.externalId)], + columns: { + id: true, + externalId: true, + workspaceId: true, + environment: true, + meta: true, + createdAt: true, + updatedAt: true, + }, + }); + + const transformedIdentities = identitiesQuery.map((identity) => ({ + id: identity.id, + externalId: identity.externalId, + workspaceId: identity.workspaceId, + environment: identity.environment, + meta: identity.meta, + createdAt: identity.createdAt, + updatedAt: identity.updatedAt ? identity.updatedAt : null, + })); + + return { + identities: transformedIdentities, + }; + } catch (error) { + console.error("Error searching identities:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to search identities. If this issue persists, please contact support@unkey.dev with the time this occurred.", + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 86a6da9ebe..5ebb138de0 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -36,6 +36,7 @@ import { upsertRole } from "./authorization/roles/upsert"; import { queryUsage } from "./billing/query-usage"; import { createIdentity } from "./identity/create"; import { queryIdentities } from "./identity/query"; +import { searchIdentities } from "./identity/search"; import { createKey } from "./key/create"; import { createRootKey } from "./key/createRootKey"; import { deleteKeys } from "./key/delete"; @@ -291,6 +292,7 @@ export const router = t.router({ identity: t.router({ create: createIdentity, query: queryIdentities, + search: searchIdentities, }), }); diff --git a/apps/dashboard/lib/trpc/routers/key/updateOwnerId.ts b/apps/dashboard/lib/trpc/routers/key/updateOwnerId.ts index 4b73be308f..7678883b00 100644 --- a/apps/dashboard/lib/trpc/routers/key/updateOwnerId.ts +++ b/apps/dashboard/lib/trpc/routers/key/updateOwnerId.ts @@ -195,7 +195,7 @@ const updateOwnerV2 = async ( .update(schema.keys) .set({ identityId: input.identity.id ?? null, - ownerId: input.identity.externalId, + ownerId: input.identity.externalId ?? null, }) .where( and(