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(