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}>
|
<UserDropdownRoot fullName={user.fullName} email={user.email}>
|
||||||
{isAuthorized("Organization", "deleteOrganization") && (
|
{isAuthorized("Organization", "deleteOrganization") && (
|
||||||
<UserDropdownItem
|
<UserDropdownItem
|
||||||
to="/api-keys"
|
to="/me/api-keys"
|
||||||
icon={IconKey}
|
icon={IconKey}
|
||||||
label={__("API Keys")}
|
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}>
|
<UserDropdown fullName={fullName} email={email}>
|
||||||
{isAuthorized("Organization", "deleteOrganization") && (
|
{isAuthorized("Organization", "deleteOrganization") && (
|
||||||
<UserDropdownItem
|
<UserDropdownItem
|
||||||
to="/api-keys"
|
to="/me/api-keys"
|
||||||
icon={IconKey}
|
icon={IconKey}
|
||||||
label={__("API Keys")}
|
label={__("API Keys")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ const routes = [
|
|||||||
Component: lazy(() => import("./pages/DocumentSigningRequestsPage")),
|
Component: lazy(() => import("./pages/DocumentSigningRequestsPage")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "api-keys",
|
path: "me/api-keys",
|
||||||
Component: lazy(() => import("./pages/APIKeysPage")),
|
Component: lazy(() => import("./pages/iam/apiKeys/APIKeysPageLoader")),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user