Update api key page to use iam api

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-12-29 11:49:01 +01:00
parent c4699e433f
commit 9330f454f0
14 changed files with 1357 additions and 850 deletions

View File

@@ -379,7 +379,7 @@ function UserDropdown() {
<UserDropdownRoot fullName={user.fullName} email={user.email}>
{isAuthorized("Organization", "deleteOrganization") && (
<UserDropdownItem
to="/api-keys"
to="/me/api-keys"
icon={IconKey}
label={__("API Keys")}
/>

View File

@@ -1,846 +0,0 @@
import { useState, useEffect } from "react";
import {
Button,
Card,
Dialog,
DialogContent,
DialogFooter,
Field,
IconTrashCan,
IconPlusLarge,
IconPencil,
IconSquareBehindSquare2,
Label,
Select,
Badge,
useConfirm,
useDialogRef,
Option,
useToast,
Checkbox,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Input,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { formatDate } from "@probo/helpers";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { Controller } from "react-hook-form";
import { z } from "zod";
import { UnAuthenticatedError } from "@probo/relay";
interface APIKey {
id: string;
name: string;
expiresAt: string;
createdAt: string;
organizations: APIKeyOrganization[];
}
interface APIKeyOrganization {
organizationId: string;
organizationName: string;
role: string;
}
interface Organization {
id: string;
name: string;
authStatus: "authenticated" | "unauthenticated" | "expired";
}
const createSchema = z.object({
name: z.string().min(1, "Name is required"),
expiresIn: z.enum(["1month", "3months", "6months", "1year"]),
organizations: z
.array(
z.object({
organizationId: z.string(),
role: z.string(),
})
)
.min(1, "At least one organization is required"),
});
type CreateFormData = z.infer<typeof createSchema>;
export default function APIKeysPage() {
const { __ } = useTranslate();
const { toast } = useToast();
const confirm = useConfirm();
const dialogRef = useDialogRef();
const editDialogRef = useDialogRef();
const keyDialogRef = useDialogRef();
const [currentKey, setCurrentKey] = useState<string | null>(null);
const [isLoadingKey, setIsLoadingKey] = useState(false);
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [selectedOrganizations, setSelectedOrganizations] = useState<string[]>(
[]
);
const [organizationRoles, setOrganizationRoles] = useState<
Record<string, string>
>({});
const [editingAPIKey, setEditingAPIKey] = useState<APIKey | null>(null);
const [editingName, setEditingName] = useState<string>("");
const [error, setError] = useState<Error | null>(null);
const { formState, handleSubmit, reset, control, setValue, register } =
useFormWithSchema(createSchema, {
defaultValues: {
name: new Date().toISOString().split("T")[0],
expiresIn: "1month",
organizations: [],
},
});
if (error) {
throw error;
}
const fetchAPIKeys = async () => {
try {
const response = await fetch("/connect/api-keys", {
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to fetch API keys");
}
const data: { apiKeys: APIKey[] } = await response.json();
setApiKeys(data.apiKeys);
} catch (err) {
console.error("Failed to fetch API keys:", err);
toast({
title: __("Error"),
description: __("Failed to load API keys"),
variant: "error",
});
}
};
useEffect(() => {
const fetchData = async () => {
try {
const [apiKeysResponse, organizationsResponse] = await Promise.all([
fetch("/connect/api-keys", { credentials: "include" }),
fetch("/connect/organizations?role=OWNER", {
credentials: "include",
}),
]);
if (
apiKeysResponse.status === 401 ||
organizationsResponse.status === 401
) {
setError(new UnAuthenticatedError());
return;
}
if (!apiKeysResponse.ok) {
throw new Error("Failed to fetch API keys");
}
if (!organizationsResponse.ok) {
throw new Error("Failed to fetch organizations");
}
const apiKeysData: { apiKeys: APIKey[] } = await apiKeysResponse.json();
const orgsData: { organizations: Organization[] } =
await organizationsResponse.json();
const authenticatedOrgs = orgsData.organizations.filter(
(org) => org.authStatus === "authenticated"
);
setApiKeys(apiKeysData.apiKeys);
setOrganizations(authenticatedOrgs);
} catch (err) {
console.error("Failed to fetch data:", err);
toast({
title: __("Error"),
description: __("Failed to load data"),
variant: "error",
});
} finally {
setIsLoading(false);
}
};
fetchData();
}, [__, toast]);
const handleCreate = async (formData: CreateFormData) => {
const now = new Date();
const expiresAt = new Date(now);
switch (formData.expiresIn) {
case "1month":
expiresAt.setMonth(now.getMonth() + 1);
break;
case "3months":
expiresAt.setMonth(now.getMonth() + 3);
break;
case "6months":
expiresAt.setMonth(now.getMonth() + 6);
break;
case "1year":
expiresAt.setFullYear(now.getFullYear() + 1);
break;
}
setIsCreating(true);
try {
const response = await fetch("/connect/api-keys", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
name: formData.name,
expiresAt: expiresAt.toISOString(),
organizations: formData.organizations,
}),
});
if (!response.ok) {
throw new Error("Failed to create API key");
}
const data: { apiKey: APIKey; key: string } = await response.json();
await fetchAPIKeys();
dialogRef.current?.close();
reset();
setSelectedOrganizations([]);
setOrganizationRoles({});
setCurrentKey(data.key);
keyDialogRef.current?.open();
toast({
title: __("Success"),
description: __("API Key created successfully"),
variant: "success",
});
} catch (error) {
toast({
title: __("Error"),
description: (error as Error).message,
variant: "error",
});
} finally {
setIsCreating(false);
}
};
const handleEdit = (apiKey: APIKey) => {
setEditingAPIKey(apiKey);
setEditingName(apiKey.name);
const orgIds = apiKey.organizations.map((org) => org.organizationId);
const roles: Record<string, string> = {};
apiKey.organizations.forEach((org) => {
roles[org.organizationId] = org.role;
});
setSelectedOrganizations(orgIds);
setOrganizationRoles(roles);
editDialogRef.current?.open();
};
const handleUpdate = async () => {
if (!editingAPIKey) return;
setIsUpdating(true);
try {
const response = await fetch("/connect/api-keys", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
id: editingAPIKey.id,
name: editingName,
organizations: selectedOrganizations.map((id) => ({
organizationId: id,
role: organizationRoles[id] || "FULL",
})),
}),
});
if (!response.ok) {
throw new Error("Failed to update API key");
}
await fetchAPIKeys();
editDialogRef.current?.close();
setEditingAPIKey(null);
setEditingName("");
setSelectedOrganizations([]);
setOrganizationRoles({});
toast({
title: __("Success"),
description: __("API Key updated successfully"),
variant: "success",
});
} catch (error) {
toast({
title: __("Error"),
description: (error as Error).message,
variant: "error",
});
} finally {
setIsUpdating(false);
}
};
const handleDelete = (id: string, name: string) => {
confirm(
async () => {
setIsDeleting(true);
try {
const response = await fetch("/connect/api-keys", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ id }),
});
if (!response.ok) {
throw new Error("Failed to delete API key");
}
setApiKeys(apiKeys.filter((key) => key.id !== id));
toast({
title: __("Success"),
description: __("API Key deleted successfully"),
variant: "success",
});
} catch (error) {
toast({
title: __("Error"),
description: (error as Error).message,
variant: "error",
});
throw error;
} finally {
setIsDeleting(false);
}
},
{
message: __(
`Are you sure you want to delete the API key "${name}"? This action cannot be undone.`
),
}
);
};
const handleShowToken = async (id: string) => {
setIsLoadingKey(true);
try {
const response = await fetch(`/connect/api-keys/${id}`, {
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to load API key");
}
const data: { key: string } = await response.json();
setCurrentKey(data.key);
keyDialogRef.current?.open();
} catch {
toast({
title: __("Error"),
description: __("Failed to load API key"),
variant: "error",
});
} finally {
setIsLoadingKey(false);
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast({
title: __("Success"),
description: __("API key copied to clipboard"),
variant: "success",
});
} catch {
toast({
title: __("Error"),
description: __("Failed to copy to clipboard"),
variant: "error",
});
}
};
const isExpired = (expiresAt: string) => {
return new Date(expiresAt) < new Date();
};
if (isLoading) {
return (
<div className="space-y-6 w-full py-6">
<h1 className="text-3xl font-bold text-center">{__("API Keys")}</h1>
<Card padded>
<div className="text-center py-8">
<p className="text-txt-tertiary">{__("Loading...")}</p>
</div>
</Card>
</div>
);
}
return (
<div className="space-y-6 w-full py-6">
<h1 className="text-3xl font-bold text-center">{__("API Keys")}</h1>
<div className="space-y-4 w-full">
{apiKeys.length === 0 ? (
<Card padded>
<div className="text-center py-8">
<p className="text-txt-tertiary mb-4">
{__("No API keys yet. Create one to get started.")}
</p>
</div>
</Card>
) : (
apiKeys.map((apiKey) => {
const expired = isExpired(apiKey.expiresAt);
return (
<Card key={apiKey.id} padded className="w-full">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">{apiKey.name}</h3>
{expired ? (
<Badge variant="danger">{__("Expired")}</Badge>
) : (
<Badge variant="success">{__("Active")}</Badge>
)}
</div>
<div className="flex items-center gap-4 text-sm text-txt-secondary">
<span>
{__("Created")}: {formatDate(apiKey.createdAt)}
</span>
<span>•</span>
<span>
{__("Expires")}: {formatDate(apiKey.expiresAt)}
</span>
</div>
{apiKey.organizations.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{apiKey.organizations.map((org) => (
<Badge key={org.organizationId} variant="neutral">
{org.organizationName} ({org.role})
</Badge>
))}
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => handleShowToken(apiKey.id)}
disabled={isLoadingKey}
title={__("Show Token")}
>
{__("Show")}
</Button>
<Button
variant="secondary"
onClick={() => handleEdit(apiKey)}
disabled={isLoadingKey}
icon={IconPencil}
title={__("Edit")}
/>
<Button
variant="danger"
onClick={() => handleDelete(apiKey.id, apiKey.name)}
disabled={isDeleting}
icon={IconTrashCan}
title={__("Delete")}
/>
</div>
</div>
</Card>
);
})
)}
<Card padded>
<h2 className="text-xl font-semibold mb-1">
{__("Create an API key")}
</h2>
<p className="text-txt-tertiary mb-4">
{__(
"Generate a new API key for programmatic access to your organization"
)}
</p>
<Button
onClick={() => dialogRef.current?.open()}
variant="quaternary"
icon={IconPlusLarge}
className="w-full"
>
{__("Create API Key")}
</Button>
</Card>
</div>
<Dialog ref={dialogRef} title={__("Create API Key")}>
<form onSubmit={handleSubmit(handleCreate)}>
<DialogContent padded className="space-y-4">
<Field error={formState.errors.name?.message}>
<Label>{__("Name")}</Label>
<Input
{...register("name")}
placeholder={__("e.g., Production API Key")}
/>
</Field>
<Field error={formState.errors.expiresIn?.message}>
<Label>{__("Expires In")}</Label>
<Controller
control={control}
name="expiresIn"
render={({ field }) => (
<Select
{...field}
onValueChange={field.onChange}
value={field.value}
>
<Option value="1month">{__("1 Month")}</Option>
<Option value="3months">{__("3 Months")}</Option>
<Option value="6months">{__("6 Months")}</Option>
<Option value="1year">{__("1 Year")}</Option>
</Select>
)}
/>
</Field>
<Field error={formState.errors.organizations?.message}>
<div className="flex justify-between items-center mb-4">
<h4 className="font-medium text-txt-primary">
{__("Organizations")}
</h4>
{organizations.length > 0 && (
<Button
type="button"
variant="tertiary"
onClick={() => {
const allSelected =
selectedOrganizations.length === organizations.length;
if (allSelected) {
setSelectedOrganizations([]);
setOrganizationRoles({});
setValue("organizations", []);
} else {
const allOrgIds = organizations.map((org) => org.id);
const newRoles: Record<string, string> = {};
allOrgIds.forEach((id) => {
newRoles[id] = organizationRoles[id] || "FULL";
});
setSelectedOrganizations(allOrgIds);
setOrganizationRoles(newRoles);
setValue(
"organizations",
allOrgIds.map((id) => ({
organizationId: id,
role: newRoles[id],
}))
);
}
}}
className="text-xs h-7 min-h-7"
>
{selectedOrganizations.length === organizations.length
? __("Clear All")
: __("Select All")}
</Button>
)}
</div>
{organizations.length === 0 ? (
<div className="text-center text-txt-tertiary py-8">
{__("No organizations available")}
</div>
) : (
<div className="bg-bg-secondary rounded-lg overflow-hidden">
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th width={180}>{__("Role")}</Th>
<Th width={100}>
<div className="flex justify-end">{__("Access")}</div>
</Th>
</Tr>
</Thead>
<Tbody>
{organizations.map((org) => (
<Tr key={org.id}>
<Td>
<div className="font-medium text-txt-primary">
{org.name}
</div>
</Td>
<Td>
<div className="min-h-[36px] flex items-center">
{selectedOrganizations.includes(org.id) ? (
<Select
value={organizationRoles[org.id] || "FULL"}
onValueChange={(role) => {
const newRoles = {
...organizationRoles,
[org.id]: role,
};
setOrganizationRoles(newRoles);
setValue(
"organizations",
selectedOrganizations.map((id) => ({
organizationId: id,
role: newRoles[id] || "FULL",
}))
);
}}
>
<Option value="FULL">{__("Full")}</Option>
</Select>
) : (
<span className="text-txt-tertiary">—</span>
)}
</div>
</Td>
<Td>
<div className="flex justify-end">
<Checkbox
checked={selectedOrganizations.includes(org.id)}
onChange={(checked: boolean) => {
let newSelected: string[];
const newRoles = { ...organizationRoles };
if (checked) {
newSelected = [
...selectedOrganizations,
org.id,
];
if (!newRoles[org.id]) {
newRoles[org.id] = "FULL";
}
} else {
newSelected = selectedOrganizations.filter(
(id) => id !== org.id
);
delete newRoles[org.id];
}
setSelectedOrganizations(newSelected);
setOrganizationRoles(newRoles);
setValue(
"organizations",
newSelected.map((id) => ({
organizationId: id,
role: newRoles[id] || "FULL",
}))
);
}}
/>
</div>
</Td>
</Tr>
))}
</Tbody>
</Table>
</div>
)}
</Field>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isCreating}>
{isCreating ? __("Creating...") : __("Create")}
</Button>
</DialogFooter>
</form>
</Dialog>
<Dialog ref={editDialogRef} title={__("Edit API Key")}>
<DialogContent padded className="space-y-6">
<Field>
<Label>{__("Name")}</Label>
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
placeholder={__("API Key Name")}
/>
</Field>
<div>
<div className="flex justify-between items-center mb-4">
<h4 className="font-medium text-txt-primary">
{__("Organizations")}
</h4>
{organizations.length > 0 && (
<Button
type="button"
variant="tertiary"
onClick={() => {
const allSelected =
selectedOrganizations.length === organizations.length;
if (allSelected) {
setSelectedOrganizations([]);
setOrganizationRoles({});
} else {
const allOrgIds = organizations.map((org) => org.id);
const newRoles: Record<string, string> = {};
allOrgIds.forEach((id) => {
newRoles[id] = organizationRoles[id] || "FULL";
});
setSelectedOrganizations(allOrgIds);
setOrganizationRoles(newRoles);
}
}}
className="text-xs h-7 min-h-7"
>
{selectedOrganizations.length === organizations.length
? __("Clear All")
: __("Select All")}
</Button>
)}
</div>
{organizations.length === 0 ? (
<div className="text-center text-txt-tertiary py-8">
{__("No organizations available")}
</div>
) : (
<div className="bg-bg-secondary rounded-lg overflow-hidden">
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th width={180}>{__("Role")}</Th>
<Th width={100}>
<div className="flex justify-end">{__("Access")}</div>
</Th>
</Tr>
</Thead>
<Tbody>
{organizations.map((org) => (
<Tr key={org.id}>
<Td>
<div className="font-medium text-txt-primary">
{org.name}
</div>
</Td>
<Td>
<div className="min-h-[36px] flex items-center">
{selectedOrganizations.includes(org.id) ? (
<Select
value={organizationRoles[org.id] || "FULL"}
onValueChange={(role) => {
const newRoles = {
...organizationRoles,
[org.id]: role,
};
setOrganizationRoles(newRoles);
}}
>
<Option value="FULL">{__("Full")}</Option>
</Select>
) : (
<span className="text-txt-tertiary">—</span>
)}
</div>
</Td>
<Td>
<div className="flex justify-end">
<Checkbox
checked={selectedOrganizations.includes(org.id)}
onChange={(checked: boolean) => {
let newSelected: string[];
const newRoles = { ...organizationRoles };
if (checked) {
newSelected = [
...selectedOrganizations,
org.id,
];
if (!newRoles[org.id]) {
newRoles[org.id] = "FULL";
}
} else {
newSelected = selectedOrganizations.filter(
(id) => id !== org.id
);
delete newRoles[org.id];
}
setSelectedOrganizations(newSelected);
setOrganizationRoles(newRoles);
}}
/>
</div>
</Td>
</Tr>
))}
</Tbody>
</Table>
</div>
)}
</div>
</DialogContent>
<DialogFooter>
<Button
onClick={handleUpdate}
disabled={
isUpdating ||
(selectedOrganizations.length === 0 && !editingName.trim())
}
>
{isUpdating ? __("Updating...") : __("Update")}
</Button>
</DialogFooter>
</Dialog>
<Dialog ref={keyDialogRef} title={__("API Key")}>
<DialogContent padded className="space-y-4">
<p className="text-sm text-txt-tertiary">
{__("Please save this API key securely.")}
</p>
<div className="bg-gray-100 p-4 rounded-lg flex items-center gap-2">
<code className="text-sm font-mono break-all flex-1">
{currentKey || ""}
</code>
<Button
onClick={() => {
if (currentKey) {
copyToClipboard(currentKey);
}
}}
variant="secondary"
disabled={!currentKey}
title={__("Copy to Clipboard")}
>
<IconSquareBehindSquare2 size={16} />
</Button>
</div>
</DialogContent>
<DialogFooter>
<Button
onClick={() => {
keyDialogRef.current?.close();
setCurrentKey(null);
}}
>
{__("Done")}
</Button>
</DialogFooter>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { graphql, usePreloadedQuery, type PreloadedQuery } from "react-relay";
import { useTranslate } from "@probo/i18n";
import type { APIKeysPageQuery } from "./__generated__/APIKeysPageQuery.graphql";
import { PersonalAPIKeyList } from "./_components/PersonalAPIKeyList";
export const apiKeysPageQuery = graphql`
query APIKeysPageQuery {
viewer {
...PersonalAPIKeyListFragment
}
}
`;
export function APIKeysPage(props: {
queryRef: PreloadedQuery<APIKeysPageQuery>;
}) {
const { queryRef } = props;
const { __ } = useTranslate();
const data = usePreloadedQuery<APIKeysPageQuery>(apiKeysPageQuery, queryRef);
return (
<div className="space-y-6 w-full py-6">
<h1 className="text-3xl font-bold text-center">{__("API Keys")}</h1>
{data.viewer && <PersonalAPIKeyList fKey={data.viewer} />}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { CenteredLayoutSkeleton } from "@probo/ui";
import { APIKeysPage, apiKeysPageQuery } from "./APIKeysPage";
import type { APIKeysPageQuery } from "./__generated__/APIKeysPageQuery.graphql";
import { IAMRelayProvider } from "/providers/IAMRelayProvider";
function APIKeysPageLoaderInner() {
const [queryRef, loadQuery] =
useQueryLoader<APIKeysPageQuery>(apiKeysPageQuery);
useEffect(() => {
loadQuery({});
}, [loadQuery]);
if (!queryRef) {
return <CenteredLayoutSkeleton />;
}
return (
<Suspense fallback={<CenteredLayoutSkeleton />}>
<APIKeysPage queryRef={queryRef} />
</Suspense>
);
}
export default function APIKeysPageLoader() {
return (
<IAMRelayProvider>
<APIKeysPageLoaderInner />
</IAMRelayProvider>
);
}

View File

@@ -0,0 +1,209 @@
/**
* @generated SignedSource<<83cc00d864e6388ef2156203499da849>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type APIKeysPageQuery$variables = Record<PropertyKey, never>;
export type APIKeysPageQuery$data = {
readonly viewer: {
readonly " $fragmentSpreads": FragmentRefs<"PersonalAPIKeyListFragment">;
} | null | undefined;
};
export type APIKeysPageQuery = {
response: APIKeysPageQuery$data;
variables: APIKeysPageQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v1 = [
{
"kind": "Literal",
"name": "first",
"value": 1000
}
];
return {
"fragment": {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "APIKeysPageQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Identity",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
{
"args": null,
"kind": "FragmentSpread",
"name": "PersonalAPIKeyListFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "APIKeysPageQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Identity",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": "PersonalAPIKeyConnection",
"kind": "LinkedField",
"name": "personalAPIKeys",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PersonalAPIKeyEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PersonalAPIKey",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "expiresAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "lastUsedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "personalAPIKeys(first:1000)"
},
{
"alias": null,
"args": (v1/*: any*/),
"filters": null,
"handle": "connection",
"key": "PersonalAPIKeyListFragment_personalAPIKeys",
"kind": "LinkedHandle",
"name": "personalAPIKeys"
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "14e698e7b6edf0afe9cf3398986cd529",
"id": null,
"metadata": {},
"name": "APIKeysPageQuery",
"operationKind": "query",
"text": "query APIKeysPageQuery {\n viewer {\n ...PersonalAPIKeyListFragment\n id\n }\n}\n\nfragment PersonalAPIKeyListFragment on Identity {\n id\n personalAPIKeys(first: 1000) {\n edges {\n node {\n id\n name\n createdAt\n expiresAt\n lastUsedAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n"
}
};
})();
(node as any).hash = "e99d7224e11d4e9060518c3155f98a7d";
export default node;

View File

@@ -0,0 +1,393 @@
import { useState } from "react";
import { Controller } from "react-hook-form";
import {
ConnectionHandler,
graphql,
useFragment,
useMutation,
} from "react-relay";
import type { RecordSourceSelectorProxy } from "relay-runtime";
import { z } from "zod";
import { useTranslate } from "@probo/i18n";
import { formatError, type GraphQLError } from "@probo/helpers";
import {
Breadcrumb,
Button,
Card,
Dialog,
DialogContent,
DialogFooter,
Field,
Input,
Label,
Option,
Select,
useConfirm,
useDialogRef,
useToast,
} from "@probo/ui";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import type { PersonalAPIKeyListFragment$key } from "./__generated__/PersonalAPIKeyListFragment.graphql";
import type { PersonalAPIKeyListCreateMutation } from "./__generated__/PersonalAPIKeyListCreateMutation.graphql";
import type { PersonalAPIKeyListRevokeMutation } from "./__generated__/PersonalAPIKeyListRevokeMutation.graphql";
import type { PersonalAPIKeyListRevealTokenMutation } from "./__generated__/PersonalAPIKeyListRevealTokenMutation.graphql";
import { PersonalAPIKeysTable } from "./PersonalAPIKeysTable";
import { PersonalAPIKeyTokenDialog } from "./PersonalAPIKeyTokenDialog";
const fragment = graphql`
fragment PersonalAPIKeyListFragment on Identity {
id
personalAPIKeys(first: 1000)
@required(action: THROW)
@connection(key: "PersonalAPIKeyListFragment_personalAPIKeys") {
edges @required(action: THROW) {
node {
id
name
createdAt
expiresAt
lastUsedAt
}
}
}
}
`;
const createMutation = graphql`
mutation PersonalAPIKeyListCreateMutation(
$input: CreatePersonalAPIKeyInput!
$connections: [ID!]!
) {
createPersonalAPIKey(input: $input) {
personalAPIKeyEdge @prependEdge(connections: $connections) {
node {
id
name
createdAt
expiresAt
lastUsedAt
}
}
token
}
}
`;
const revokeMutation = graphql`
mutation PersonalAPIKeyListRevokeMutation(
$input: RevokePersonalAPIKeyInput!
) {
revokePersonalAPIKey(input: $input) {
success
}
}
`;
const revealTokenMutation = graphql`
mutation PersonalAPIKeyListRevealTokenMutation(
$input: RevealPersonalAPIKeyTokenInput!
) {
revealPersonalAPIKeyToken(input: $input) {
token
}
}
`;
const createSchema = z.object({
name: z.string().min(1, "Name is required"),
expiresIn: z.enum(["1month", "3months", "6months", "1year"]),
});
type CreateFormData = z.infer<typeof createSchema>;
function computeExpiresAt(expiresIn: CreateFormData["expiresIn"]) {
const now = new Date();
const expiresAt = new Date(now);
switch (expiresIn) {
case "1month":
expiresAt.setMonth(now.getMonth() + 1);
break;
case "3months":
expiresAt.setMonth(now.getMonth() + 3);
break;
case "6months":
expiresAt.setMonth(now.getMonth() + 6);
break;
case "1year":
expiresAt.setFullYear(now.getFullYear() + 1);
break;
}
return expiresAt;
}
export function PersonalAPIKeyList(props: {
fKey: PersonalAPIKeyListFragment$key;
}) {
const { fKey } = props;
const { __ } = useTranslate();
const { toast } = useToast();
const confirm = useConfirm();
const createDialogRef = useDialogRef();
const tokenDialogRef = useDialogRef();
const [token, setToken] = useState<string>("");
const viewer = useFragment(fragment, fKey);
const keys = viewer.personalAPIKeys.edges.map(({ node }) => node);
const { formState, handleSubmit, register, control, reset, watch } =
useFormWithSchema(createSchema, {
defaultValues: {
name: new Date().toISOString().split("T")[0],
expiresIn: "1month",
},
});
watch();
const [createCommit, isCreating] =
useMutation<PersonalAPIKeyListCreateMutation>(createMutation);
const [revokeCommit] =
useMutation<PersonalAPIKeyListRevokeMutation>(revokeMutation);
const [revealTokenCommit, isRevealingToken] =
useMutation<PersonalAPIKeyListRevealTokenMutation>(revealTokenMutation);
const handleCreate = (data: CreateFormData) => {
const expiresAt = computeExpiresAt(data.expiresIn);
const connectionID = ConnectionHandler.getConnectionID(
viewer.id,
"PersonalAPIKeyListFragment_personalAPIKeys"
);
createCommit({
variables: {
input: {
name: data.name,
expiresAt: expiresAt.toISOString(),
// API keys are no longer linked to organizations; keep schema compatibility.
organizationIds: [],
},
connections: [connectionID],
},
onCompleted: (response) => {
toast({
title: __("Success"),
description: __("API key created successfully."),
variant: "success",
});
const newToken = response.createPersonalAPIKey?.token;
if (newToken) {
setToken(newToken);
tokenDialogRef.current?.open();
}
createDialogRef.current?.close();
reset();
},
onError: (error) => {
toast({
title: __("Error"),
description: formatError(__("Failed to create API key."), error),
variant: "error",
});
},
});
};
const handleRevoke = (key: { id: string; name: string }) => {
confirm(
async () => {
await new Promise<void>((resolve, reject) => {
revokeCommit({
variables: {
input: { tokenId: key.id },
},
updater: (store: RecordSourceSelectorProxy) => {
const viewerRecord = store.getRoot().getLinkedRecord("viewer");
if (!viewerRecord) return;
const connection = ConnectionHandler.getConnection(
viewerRecord,
"PersonalAPIKeyListFragment_personalAPIKeys"
);
if (connection) {
ConnectionHandler.deleteNode(connection, key.id);
}
},
onCompleted: (_response, errors) => {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(
__("Failed to revoke API key."),
errors as GraphQLError[]
),
variant: "error",
});
reject(errors);
return;
}
toast({
title: __("Success"),
description: __("API key revoked successfully."),
variant: "success",
});
resolve();
},
onError: (error) => {
toast({
title: __("Error"),
description: formatError(
__("Failed to revoke API key."),
error
),
variant: "error",
});
reject(error);
},
});
});
},
{
title: __("Revoke API Key"),
message: __(
`Are you sure you want to revoke the API key "${key.name}"? This action cannot be undone.`
),
label: __("Revoke"),
variant: "danger",
}
);
};
const handleShowToken = (key: { id: string; name: string }) => {
revealTokenCommit({
variables: {
input: {
tokenId: key.id,
},
},
onCompleted: (response, errors) => {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(
__("Failed to reveal API key token."),
errors as any
),
variant: "error",
});
return;
}
const tokenValue = response.revealPersonalAPIKeyToken?.token;
if (!tokenValue) {
toast({
title: __("Error"),
description: __("No token returned."),
variant: "error",
});
return;
}
setToken(tokenValue);
tokenDialogRef.current?.open();
},
onError: (error: Error) => {
toast({
title: __("Error"),
description: formatError(
__("Failed to reveal API key token."),
error
),
variant: "error",
});
},
});
};
return (
<>
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-base font-medium">{__("API Keys")}</h2>
<Button onClick={() => createDialogRef.current?.open()}>
{__("Create API Key")}
</Button>
</div>
{keys.length === 0 ? (
<Card padded>
<div className="text-center py-12">
<h3 className="text-lg font-medium text-gray-900 mb-2">
{__("No API keys")}
</h3>
<p className="text-gray-600 mb-6">
{__("Create an API key to authenticate programmatic access.")}
</p>
</div>
</Card>
) : (
<Card padded>
<PersonalAPIKeysTable
keys={keys}
onRevoke={handleRevoke}
onShowToken={handleShowToken}
isShowingToken={isRevealingToken}
/>
</Card>
)}
</div>
<Dialog
ref={createDialogRef}
title={<Breadcrumb items={[__("API Keys"), __("Create")]} />}
onClose={() => reset()}
>
<form onSubmit={handleSubmit(handleCreate)}>
<DialogContent padded className="space-y-5">
<Field error={formState.errors.name?.message}>
<Label>{__("Name")}</Label>
<Input
{...register("name")}
placeholder={__("e.g., Production API Key")}
/>
</Field>
<Field error={formState.errors.expiresIn?.message}>
<Label>{__("Expires In")}</Label>
<Controller
control={control}
name="expiresIn"
render={({ field }) => (
<Select
{...field}
onValueChange={field.onChange}
value={field.value}
>
<Option value="1month">{__("1 Month")}</Option>
<Option value="3months">{__("3 Months")}</Option>
<Option value="6months">{__("6 Months")}</Option>
<Option value="1year">{__("1 Year")}</Option>
</Select>
)}
/>
</Field>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isCreating}>
{isCreating ? __("Creating...") : __("Create")}
</Button>
</DialogFooter>
</form>
</Dialog>
<PersonalAPIKeyTokenDialog
dialogRef={tokenDialogRef}
token={token}
onDone={() => {
tokenDialogRef.current?.close();
setToken("");
}}
/>
</>
);
}

View File

@@ -0,0 +1,45 @@
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { useCopy } from "@probo/hooks";
import { useEffect } from "react";
export function PersonalAPIKeyTokenDialog(props: {
dialogRef: React.RefObject<{ open: () => void; close: () => void } | null>;
token: string;
onDone: () => void;
}) {
const { dialogRef, token, onDone } = props;
const { __ } = useTranslate();
const [isCopied, copy] = useCopy();
useEffect(() => {}, [token]);
return (
<Dialog
ref={dialogRef}
title={<Breadcrumb items={[__("API Keys"), __("Token")]} />}
>
<DialogContent padded className="space-y-4">
<div className="bg-gray-100 p-4 rounded-lg flex items-center gap-2">
<code className="text-sm font-mono break-all flex-1">{token}</code>
<Button
variant="secondary"
onClick={() => copy(token)}
disabled={!token}
>
{isCopied ? __("Copied") : __("Copy")}
</Button>
</div>
</DialogContent>
<DialogFooter>
<Button onClick={onDone}>{__("Done")}</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,83 @@
import { useTranslate } from "@probo/i18n";
import { formatDate } from "@probo/helpers";
import { Button, Table, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
export type PersonalAPIKeyRow = {
id: string;
name: string;
createdAt: string;
expiresAt: string;
lastUsedAt: string | null;
};
export function PersonalAPIKeysTable(props: {
keys: PersonalAPIKeyRow[];
onRevoke: (key: { id: string; name: string }) => void;
onShowToken: (key: { id: string; name: string }) => void;
isShowingToken?: boolean;
}) {
const { keys, onRevoke, onShowToken, isShowingToken } = props;
const { __ } = useTranslate();
const now = new Date();
return (
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Last used")}</Th>
<Th>{__("Created")}</Th>
<Th>{__("Expires")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{keys.map((k) => {
const expired = new Date(k.expiresAt) < now;
return (
<Tr key={k.id}>
<Td>
<div className="font-medium text-txt-primary">{k.name}</div>
<div className="text-xs text-txt-tertiary">
{expired ? __("Expired") : __("Active")}
</div>
</Td>
<Td>
<span className="text-sm text-txt-secondary">
{k.lastUsedAt ? formatDate(k.lastUsedAt) : "—"}
</span>
</Td>
<Td>
<span className="text-sm text-txt-secondary">
{formatDate(k.createdAt)}
</span>
</Td>
<Td>
<span className="text-sm text-txt-secondary">
{formatDate(k.expiresAt)}
</span>
</Td>
<Td width={140} className="text-end">
<div className="flex gap-2 justify-end">
<Button
variant="secondary"
onClick={() => onShowToken({ id: k.id, name: k.name })}
disabled={!!isShowingToken}
>
{__("Show")}
</Button>
<Button
variant="danger"
onClick={() => onRevoke({ id: k.id, name: k.name })}
>
{__("Revoke")}
</Button>
</div>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
);
}

View File

@@ -0,0 +1,202 @@
/**
* @generated SignedSource<<875eae6f5455a17a1bb8e0590c7acdb0>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type CreatePersonalAPIKeyInput = {
expiresAt: any;
name: string;
organizationIds: ReadonlyArray<string>;
};
export type PersonalAPIKeyListCreateMutation$variables = {
connections: ReadonlyArray<string>;
input: CreatePersonalAPIKeyInput;
};
export type PersonalAPIKeyListCreateMutation$data = {
readonly createPersonalAPIKey: {
readonly personalAPIKeyEdge: {
readonly node: {
readonly createdAt: any;
readonly expiresAt: any;
readonly id: string;
readonly lastUsedAt: any | null | undefined;
readonly name: string;
};
};
readonly token: string;
} | null | undefined;
};
export type PersonalAPIKeyListCreateMutation = {
response: PersonalAPIKeyListCreateMutation$data;
variables: PersonalAPIKeyListCreateMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"concreteType": "PersonalAPIKeyEdge",
"kind": "LinkedField",
"name": "personalAPIKeyEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PersonalAPIKey",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "expiresAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "lastUsedAt",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "token",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "PersonalAPIKeyListCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreatePersonalAPIKeyPayload",
"kind": "LinkedField",
"name": "createPersonalAPIKey",
"plural": false,
"selections": [
(v3/*: any*/),
(v4/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "PersonalAPIKeyListCreateMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreatePersonalAPIKeyPayload",
"kind": "LinkedField",
"name": "createPersonalAPIKey",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependEdge",
"key": "",
"kind": "LinkedHandle",
"name": "personalAPIKeyEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
},
(v4/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "5b1f6109730bd9f261afd6635d568b59",
"id": null,
"metadata": {},
"name": "PersonalAPIKeyListCreateMutation",
"operationKind": "mutation",
"text": "mutation PersonalAPIKeyListCreateMutation(\n $input: CreatePersonalAPIKeyInput!\n) {\n createPersonalAPIKey(input: $input) {\n personalAPIKeyEdge {\n node {\n id\n name\n createdAt\n expiresAt\n lastUsedAt\n }\n }\n token\n }\n}\n"
}
};
})();
(node as any).hash = "c7e932ad41ff2740e8687b7edb904431";
export default node;

View File

@@ -0,0 +1,176 @@
/**
* @generated SignedSource<<e518a98137eb07e37499d945991b15ed>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type PersonalAPIKeyListFragment$data = {
readonly id: string;
readonly personalAPIKeys: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly createdAt: any;
readonly expiresAt: any;
readonly id: string;
readonly lastUsedAt: any | null | undefined;
readonly name: string;
};
}>;
};
readonly " $fragmentType": "PersonalAPIKeyListFragment";
};
export type PersonalAPIKeyListFragment$key = {
readonly " $data"?: PersonalAPIKeyListFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"PersonalAPIKeyListFragment">;
};
const node: ReaderFragment = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"personalAPIKeys"
]
}
]
},
"name": "PersonalAPIKeyListFragment",
"selections": [
(v0/*: any*/),
{
"kind": "RequiredField",
"field": {
"alias": "personalAPIKeys",
"args": null,
"concreteType": "PersonalAPIKeyConnection",
"kind": "LinkedField",
"name": "__PersonalAPIKeyListFragment_personalAPIKeys_connection",
"plural": false,
"selections": [
{
"kind": "RequiredField",
"field": {
"alias": null,
"args": null,
"concreteType": "PersonalAPIKeyEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PersonalAPIKey",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "expiresAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "lastUsedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
"action": "THROW"
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
},
"action": "THROW"
}
],
"type": "Identity",
"abstractKey": null
};
})();
(node as any).hash = "70fa6b4e568fc1598b8dc23a030428b5";
export default node;

View File

@@ -0,0 +1,92 @@
/**
* @generated SignedSource<<7a6fb678e9147fd1810e02bd09d5cef1>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type RevealPersonalAPIKeyTokenInput = {
tokenId: string;
};
export type PersonalAPIKeyListRevealTokenMutation$variables = {
input: RevealPersonalAPIKeyTokenInput;
};
export type PersonalAPIKeyListRevealTokenMutation$data = {
readonly revealPersonalAPIKeyToken: {
readonly token: string;
} | null | undefined;
};
export type PersonalAPIKeyListRevealTokenMutation = {
response: PersonalAPIKeyListRevealTokenMutation$data;
variables: PersonalAPIKeyListRevealTokenMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "RevealPersonalAPIKeyTokenPayload",
"kind": "LinkedField",
"name": "revealPersonalAPIKeyToken",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "token",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "PersonalAPIKeyListRevealTokenMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "PersonalAPIKeyListRevealTokenMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "25ffba7c8a69e22dcc46b56a6c8633ed",
"id": null,
"metadata": {},
"name": "PersonalAPIKeyListRevealTokenMutation",
"operationKind": "mutation",
"text": "mutation PersonalAPIKeyListRevealTokenMutation(\n $input: RevealPersonalAPIKeyTokenInput!\n) {\n revealPersonalAPIKeyToken(input: $input) {\n token\n }\n}\n"
}
};
})();
(node as any).hash = "7dad7ebd6e8086a01b2bac8aa80c532e";
export default node;

View File

@@ -0,0 +1,92 @@
/**
* @generated SignedSource<<84eef5bca22c2ad2b73bbe068767e35a>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type RevokePersonalAPIKeyInput = {
tokenId: string;
};
export type PersonalAPIKeyListRevokeMutation$variables = {
input: RevokePersonalAPIKeyInput;
};
export type PersonalAPIKeyListRevokeMutation$data = {
readonly revokePersonalAPIKey: {
readonly success: boolean;
} | null | undefined;
};
export type PersonalAPIKeyListRevokeMutation = {
response: PersonalAPIKeyListRevokeMutation$data;
variables: PersonalAPIKeyListRevokeMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "RevokePersonalAPIKeyPayload",
"kind": "LinkedField",
"name": "revokePersonalAPIKey",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "PersonalAPIKeyListRevokeMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "PersonalAPIKeyListRevokeMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "fd05bb0236b583ca54be466cffe8f45b",
"id": null,
"metadata": {},
"name": "PersonalAPIKeyListRevokeMutation",
"operationKind": "mutation",
"text": "mutation PersonalAPIKeyListRevokeMutation(\n $input: RevokePersonalAPIKeyInput!\n) {\n revokePersonalAPIKey(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "34ef04d19251c479827ca5028346f65b";
export default node;

View File

@@ -75,7 +75,7 @@ export function SessionDropdown(props: { fKey: SessionDropdownFragment$key }) {
<UserDropdown fullName={fullName} email={email}>
{isAuthorized("Organization", "deleteOrganization") && (
<UserDropdownItem
to="/api-keys"
to="/me/api-keys"
icon={IconKey}
label={__("API Keys")}
/>

View File

@@ -123,8 +123,8 @@ const routes = [
Component: lazy(() => import("./pages/DocumentSigningRequestsPage")),
},
{
path: "api-keys",
Component: lazy(() => import("./pages/APIKeysPage")),
path: "me/api-keys",
Component: lazy(() => import("./pages/iam/apiKeys/APIKeysPageLoader")),
},
],
},