Skip to content

feat: api keys list #3035

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 47 commits into from
Apr 15, 2025
Merged
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
aa6a856
add: initial layout
ogzhanolguncu Mar 27, 2025
553e4bd
feat: add new api keys page
ogzhanolguncu Mar 27, 2025
da356ea
feat: add color to expires badge
ogzhanolguncu Mar 27, 2025
8494012
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 27, 2025
849b43d
feat: add chart
ogzhanolguncu Mar 28, 2025
b2b7ecc
feat: finalize usage chart
ogzhanolguncu Mar 28, 2025
af5ab67
fix: expires component
ogzhanolguncu Mar 28, 2025
e21d728
fix: timestamps and chart colors
ogzhanolguncu Mar 28, 2025
07e48dc
Merge branch 'main' of github.com:unkeyed/unkey into feat-api-keys-list
ogzhanolguncu Apr 10, 2025
a37d1a8
fix: build issues
ogzhanolguncu Apr 10, 2025
1413ca3
feat: add draft of status popover
ogzhanolguncu Apr 10, 2025
f385426
fix: status cleanup
ogzhanolguncu Apr 10, 2025
b800b30
feat: finalize status design
ogzhanolguncu Apr 10, 2025
1d4b833
fix: Style issues
ogzhanolguncu Apr 11, 2025
24fbe9d
feat: add configurable skeletons to table
ogzhanolguncu Apr 11, 2025
cdbecd0
fix: identitiy
ogzhanolguncu Apr 11, 2025
050697b
fix: total count
ogzhanolguncu Apr 11, 2025
cb61431
chore: remove unused component
ogzhanolguncu Apr 11, 2025
6f90823
feat: add two new operator to key id filter
ogzhanolguncu Apr 11, 2025
3a6d9fe
fix: type issues
ogzhanolguncu Apr 11, 2025
681cad1
fix: identity filter
ogzhanolguncu Apr 11, 2025
4c7d1a0
feat: add ai saerch to keys
ogzhanolguncu Apr 11, 2025
58fd6e8
fix: add status condition for disabled
ogzhanolguncu Apr 11, 2025
8a7f25e
fix: some styling issues
ogzhanolguncu Apr 14, 2025
2376a0e
refactor: improve schema structure
ogzhanolguncu Apr 14, 2025
612d905
chore: remove unused types
ogzhanolguncu Apr 14, 2025
04d91dc
fix: navbar
ogzhanolguncu Apr 14, 2025
b710882
Merge branch 'main' into feat-api-keys-list
ogzhanolguncu Apr 14, 2025
02cba07
feat: add dynamic key to popover menu
ogzhanolguncu Apr 14, 2025
9007b93
Merge branch 'main' of https://github.com/unkeyed/unkey into feat-api…
chronark Apr 14, 2025
62c6487
fix: coderabbit issues
ogzhanolguncu Apr 14, 2025
7db1b12
fix: number formatting and filtering logic for outcome explainer
ogzhanolguncu Apr 14, 2025
14e2d1a
fix: empty state message
ogzhanolguncu Apr 14, 2025
f9d7d73
refactor: refinements
ogzhanolguncu Apr 14, 2025
941b8a3
chore: remove old keys
ogzhanolguncu Apr 14, 2025
2c60549
fix: import paths
ogzhanolguncu Apr 14, 2025
7893a22
Merge branch 'main' into feat-api-keys-list
ogzhanolguncu Apr 14, 2025
619a1b5
Merge branch 'main' into feat-api-keys-list
chronark Apr 14, 2025
00547f0
Merge branch 'main' of https://github.com/unkeyed/unkey into feat-api…
chronark Apr 14, 2025
4a33253
Merge branch 'feat-api-keys-list' of https://github.com/unkeyed/unkey…
chronark Apr 14, 2025
3016bc7
fix: header wording
ogzhanolguncu Apr 14, 2025
13d552f
fix: secret hover (#3111)
chronark Apr 14, 2025
db22901
Merge branch 'main' of https://github.com/unkeyed/unkey into feat-api…
chronark Apr 15, 2025
1b7319f
Merge branch 'feat-api-keys-list' of https://github.com/unkeyed/unkey…
chronark Apr 15, 2025
074cc42
revert: bad commits
chronark Apr 15, 2025
e3dc8b0
fix: merge errors
chronark Apr 15, 2025
e7af653
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/agent/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ require (
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/ClickHouse/ch-go v0.65.0 // indirect
github.com/ClickHouse/ch-go v0.64.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Microsoft/hcsshim v0.12.6 // indirect
Expand Down
4 changes: 2 additions & 2 deletions apps/agent/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/ClickHouse/ch-go v0.65.0 h1:vZAXfTQliuNNefqkPDewX3kgRxN6Q4vUENnnY+ynTRY=
github.com/ClickHouse/ch-go v0.65.0/go.mod h1:tCM0XEH5oWngoi9Iu/8+tjPBo04I/FxNIffpdjtwx3k=
github.com/ClickHouse/ch-go v0.64.1 h1:FWpP+QU4KchgzpEekuv8YoI/fUc4H2r6Bwc5WwrzvcI=
github.com/ClickHouse/ch-go v0.64.1/go.mod h1:RBUynvczWwVzhS6Up9lPKlH1mrk4UAmle6uzCiW4Pkc=
github.com/ClickHouse/clickhouse-go/v2 v2.31.0 h1:9MNHRDYXjFTJizGEJM1DfYAqdra/ohprPoZ+LPiuHXQ=
github.com/ClickHouse/clickhouse-go/v2 v2.31.0/go.mod h1:V1aZaG0ctMbd8KVi+D4loXi97duWYtHiQHMCgipKJcI=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ export function useKeysOverviewLogsQuery({ apiId, limit = 50 }: UseLogsQueryPara
isLoading: isLoadingInitial,
} = trpc.api.keys.query.useInfiniteQuery(queryParams, {
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialCursor: { requestId: null, time: null },
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
refetchOnWindowFocus: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,7 @@ export const keysQueryOverviewLogsPayload = z.object({
endTime: z.number().int(),
apiId: z.string(),
since: z.string(),
cursor: z
.object({
requestId: z.string().nullable(),
time: z.number().nullable(),
})
.optional()
.nullable(),
cursor: z.number().nullable().optional().nullable(),
outcomes: z
.array(
z.object({
Expand Down
66 changes: 66 additions & 0 deletions apps/dashboard/app/(app)/apis/[apiId]/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { getOrgId } from "@/lib/auth";
import { and, db, eq, isNull } from "@/lib/db";
import { apis } from "@unkey/db/src/schema";
import { notFound } from "next/navigation";

export type ApiLayoutData = {
currentApi: {
id: string;
name: string;
workspaceId: string;
keyAuthId: string | null;
};
workspaceApis: {
id: string;
name: string;
}[];
};

export const fetchApiAndWorkspaceDataFromDb = async (apiId: string): Promise<ApiLayoutData> => {
const orgId = await getOrgId();
if (!apiId || !orgId) {
console.error("fetchApiLayoutDataFromDb: apiId or orgId is missing");
notFound();
}

const currentApi = await db.query.apis.findFirst({
where: (table, { and, eq, isNull }) => and(eq(table.id, apiId), isNull(table.deletedAtM)),
with: {
workspace: {
columns: {
id: true,
orgId: true,
},
},
},
columns: {
id: true,
name: true,
workspaceId: true,
keyAuthId: true,
},
});

if (!currentApi || currentApi.workspace.orgId !== orgId) {
console.warn(`DB Validation failed: API ${apiId} not found or org mismatch for org ${orgId}`);
notFound();
}

const workspaceId = currentApi.workspaceId;

const workspaceApis = await db
.select({ id: apis.id, name: apis.name })
.from(apis)
.where(and(eq(apis.workspaceId, workspaceId), isNull(apis.deletedAtM)))
.orderBy(apis.name);

return {
currentApi: {
id: currentApi.id,
name: currentApi.name,
workspaceId: currentApi.workspaceId,
keyAuthId: currentApi.keyAuthId,
},
workspaceApis,
};
};
11 changes: 11 additions & 0 deletions apps/dashboard/app/(app)/apis/[apiId]/api-id-navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const ApisNavbar = ({
api,
apis,
activePage,
keyId,
}: {
api: {
id: string;
Expand All @@ -25,6 +26,7 @@ export const ApisNavbar = ({
href: string;
text: string;
};
keyId?: string;
}) => {
return (
<>
Expand Down Expand Up @@ -61,6 +63,15 @@ export const ApisNavbar = ({
label: "Settings",
href: `/apis/${api.id}/settings`,
},
...(keyId
? [
{
id: "settings",
label: `${keyId.substring(0, 8)}...${keyId.substring(keyId.length - 4)}`,
href: `/apis/${api.id}/keys/${api.keyAuthId}/${keyId}`,
},
]
: []),
]}
shortcutKey="M"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import { Minus } from "lucide-react";
import ms from "ms";
import Link from "next/link";
import { notFound } from "next/navigation";
import { fetchApiAndWorkspaceDataFromDb } from "../../../actions";
import { ApisNavbar } from "../../../api-id-navbar";
import { RBACButtons } from "./_components/rbac-buttons";
import { Navigation } from "./navigation";
import { PermissionList } from "./permission-list";
import { VerificationTable } from "./verification-table";

Expand Down Expand Up @@ -72,19 +73,12 @@ export default async function APIKeyDetailPage(props: {
return notFound();
}

const api = await db.query.apis.findFirst({
where: (table, { eq, and, isNull }) =>
and(eq(table.keyAuthId, key.keyAuthId), isNull(table.deletedAtM)),
});
if (!api) {
return notFound();
}

const { currentApi, workspaceApis } = await fetchApiAndWorkspaceDataFromDb(props.params.apiId);
const interval = props.searchParams.interval ?? "7d";

const { getVerificationsPerInterval, start, end, granularity } = prepareInterval(interval);
const query = {
workspaceId: api.workspaceId,
workspaceId: currentApi.workspaceId,
keySpaceId: key.keyAuthId,
keyId: key.id,
start,
Expand All @@ -96,17 +90,18 @@ export default async function APIKeyDetailPage(props: {
workspaceId: key.workspaceId,
keySpaceId: key.keyAuthId,
keyId: key.id,
limit: 50,
}),
clickhouse.verifications
.latest({
workspaceId: key.workspaceId,
keySpaceId: key.keyAuthId,
keyId: key.id,
limit: 1,
})
.then((res) => res.val?.at(0)?.time ?? 0),
]);

// Sort all verifications by time first
const sortedVerifications = verifications.val!.sort((a, b) => a.time - b.time);

const successOverTime: { x: string; y: number }[] = [];
Expand All @@ -117,10 +112,8 @@ export default async function APIKeyDetailPage(props: {
const expiredOverTime: { x: string; y: number }[] = [];
const forbiddenOverTime: { x: string; y: number }[] = [];

// Get all unique timestamps
const uniqueDates = [...new Set(sortedVerifications.map((d) => d.time))].sort((a, b) => a - b);

// Ensure each array has entries for all timestamps with zero counts
for (const timestamp of uniqueDates) {
const x = new Date(timestamp).toISOString();
successOverTime.push({ x, y: 0 });
Expand Down Expand Up @@ -235,7 +228,15 @@ export default async function APIKeyDetailPage(props: {

return (
<div>
<Navigation api={api} apiKey={key} />
<ApisNavbar
api={currentApi}
activePage={{
href: `/apis/${currentApi.id}/keys/${currentApi.keyAuthId}/${key.id}`,
text: "Keys",
}}
keyId={key.id}
apis={workspaceApis}
/>

<PageContent>
<div className="flex flex-col">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants";
import { ControlCloud } from "@/components/logs/control-cloud";
import { useFilters } from "../../hooks/use-filters";

const formatFieldName = (field: string): string => {
switch (field) {
case "names":
return "Name";
case "identities":
return "Identity";
case "keyIds":
return "Key ID";
default:
return field.charAt(0).toUpperCase() + field.slice(1);
}
};

export const KeysListControlCloud = () => {
const { filters, updateFilters, removeFilter } = useFilters();
return (
<ControlCloud
historicalWindow={HISTORICAL_DATA_WINDOW}
formatFieldName={formatFieldName}
filters={filters}
removeFilter={removeFilter}
updateFilters={updateFilters}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { FiltersPopover } from "@/components/logs/checkbox/filters-popover";

import { FilterOperatorInput } from "@/components/logs/filter-operator-input";
import { BarsFilter } from "@unkey/icons";
import { Button } from "@unkey/ui";
import { cn } from "@unkey/ui/src/lib/utils";
import { keysListFilterFieldConfig } from "../../../../filters.schema";
import { useFilters } from "../../../../hooks/use-filters";

export const LogsFilters = () => {
const { filters, updateFilters } = useFilters();

const options = keysListFilterFieldConfig.names.operators.map((op) => ({
id: op,
label: op,
}));
const activeNameFilter = filters.find((f) => f.field === "names");
const activeIdentityFilter = filters.find((f) => f.field === "identities");
const activeKeyIdsFilter = filters.find((f) => f.field === "keyIds");
const keyIdOptions = keysListFilterFieldConfig.names.operators.map((op) => ({
id: op,
label: op,
}));
return (
<FiltersPopover
items={[
{
id: "names",
label: "Name",
shortcut: "n",
component: (
<FilterOperatorInput
label="Name"
options={options}
defaultOption={activeNameFilter?.operator}
defaultText={activeNameFilter?.value as string}
onApply={(id, text) => {
const activeFiltersWithoutNames = filters.filter((f) => f.field !== "names");
updateFilters([
...activeFiltersWithoutNames,
{
field: "names",
id: crypto.randomUUID(),
operator: id,
value: text,
},
]);
}}
/>
),
},
{
id: "identities",
label: "Identity",
shortcut: "i",
component: (
<FilterOperatorInput
label="Identity"
options={options}
defaultOption={activeIdentityFilter?.operator}
defaultText={activeIdentityFilter?.value as string}
onApply={(id, text) => {
const activeFiltersWithoutNames = filters.filter((f) => f.field !== "identities");
updateFilters([
...activeFiltersWithoutNames,
{
field: "identities",
id: crypto.randomUUID(),
operator: id,
value: text,
},
]);
}}
/>
),
},
{
id: "keyids",
label: "Key ID",
shortcut: "k",
component: (
<FilterOperatorInput
label="Key ID"
options={keyIdOptions}
defaultOption={activeKeyIdsFilter?.operator}
defaultText={activeKeyIdsFilter?.value as string}
onApply={(id, text) => {
const activeFiltersWithoutKeyIds = filters.filter((f) => f.field !== "keyIds");
updateFilters([
...activeFiltersWithoutKeyIds,
{
field: "keyIds",
id: crypto.randomUUID(),
operator: id,
value: text,
},
]);
}}
/>
),
},
]}
activeFilters={filters}
>
<div className="group">
<Button
variant="ghost"
className={cn(
"group-data-[state=open]:bg-gray-4 px-2 rounded-lg",
filters.length > 0 ? "bg-gray-4" : "",
)}
aria-label="Filter logs"
aria-haspopup="true"
size="md"
title="Press 'F' to toggle filters"
>
<BarsFilter className="text-accent-9 size-4" />
<span className="text-accent-12 font-medium text-[13px]">Filter</span>
{filters.length > 0 && (
<div className="bg-gray-7 rounded h-4 px-1 text-[11px] font-medium text-accent-12 text-center flex items-center justify-center">
{filters.length}
</div>
)}
</Button>
</div>
</FiltersPopover>
);
};
Loading