Update api key page to use iam api
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -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")}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
28
apps/console/src/pages/iam/apiKeys/APIKeysPage.tsx
Normal file
28
apps/console/src/pages/iam/apiKeys/APIKeysPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
apps/console/src/pages/iam/apiKeys/APIKeysPageLoader.tsx
Normal file
33
apps/console/src/pages/iam/apiKeys/APIKeysPageLoader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
apps/console/src/pages/iam/apiKeys/__generated__/APIKeysPageQuery.graphql.ts
generated
Normal file
209
apps/console/src/pages/iam/apiKeys/__generated__/APIKeysPageQuery.graphql.ts
generated
Normal 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;
|
||||
@@ -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("");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
202
apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListCreateMutation.graphql.ts
generated
Normal file
202
apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListCreateMutation.graphql.ts
generated
Normal 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;
|
||||
176
apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListFragment.graphql.ts
generated
Normal file
176
apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListFragment.graphql.ts
generated
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
@@ -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")),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user