mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
Move "Api Keys" listing to filterable table (#12117)
* Move 'Api Keys' listing to filterable table * Add context menu Allow copying new keys
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { PlusIcon, TrashIcon } from "outline-icons";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, PlusIcon, TrashIcon } from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import env from "~/env";
|
||||
import type ApiKey from "~/models/ApiKey";
|
||||
import ApiKeyNew from "~/scenes/ApiKeyNew";
|
||||
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
|
||||
@@ -25,6 +28,22 @@ export const createApiKey = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy API key",
|
||||
section: SettingsSection,
|
||||
icon: <CopyIcon />,
|
||||
visible: () => !!apiKey.value,
|
||||
perform: ({ t }) => {
|
||||
copy(apiKey.value, {
|
||||
debug: env.ENVIRONMENT !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
toast.success(t("API key copied"));
|
||||
},
|
||||
});
|
||||
|
||||
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
|
||||
createAction({
|
||||
name: ({ t, isMenu }) =>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
copyApiKeyFactory,
|
||||
revokeApiKeyFactory,
|
||||
} from "~/actions/definitions/apiKeys";
|
||||
import type ApiKey from "~/models/ApiKey";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
|
||||
/**
|
||||
* Hook that constructs the action menu for API key operations.
|
||||
*
|
||||
* @param apiKey - the API key to build actions for.
|
||||
* @returns action with children for use in menus.
|
||||
*/
|
||||
export function useApiKeyMenuActions(apiKey: ApiKey) {
|
||||
const actions = useMemo(
|
||||
() => [copyApiKeyFactory({ apiKey }), revokeApiKeyFactory({ apiKey })],
|
||||
[apiKey]
|
||||
);
|
||||
return useMenuAction(actions);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type ApiKey from "~/models/ApiKey";
|
||||
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
|
||||
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
|
||||
import { revokeApiKeyFactory } from "~/actions/definitions/apiKeys";
|
||||
import { useMenuAction } from "~/hooks/useMenuAction";
|
||||
import { useApiKeyMenuActions } from "~/hooks/useApiKeyMenuActions";
|
||||
|
||||
type Props = {
|
||||
/** The apiKey to associate with the menu */
|
||||
@@ -14,11 +12,10 @@ type Props = {
|
||||
|
||||
function ApiKeyMenu({ apiKey }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const actions = useMemo(() => [revokeApiKeyFactory({ apiKey })], [apiKey]);
|
||||
const rootAction = useMenuAction(actions);
|
||||
const rootAction = useApiKeyMenuActions(apiKey);
|
||||
|
||||
return (
|
||||
<DropdownMenu action={rootAction} ariaLabel={t("API key")}>
|
||||
<DropdownMenu action={rootAction} align="end" ariaLabel={t("API key")}>
|
||||
<OverflowMenuButton />
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
+12
-1
@@ -4,8 +4,9 @@ import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import User from "./User";
|
||||
import Relation from "./decorators/Relation";
|
||||
import type { Searchable } from "./interfaces/Searchable";
|
||||
|
||||
class ApiKey extends Model {
|
||||
class ApiKey extends Model implements Searchable {
|
||||
static modelName = "ApiKey";
|
||||
|
||||
/** The human-readable name of this API key */
|
||||
@@ -53,6 +54,16 @@ class ApiKey extends Model {
|
||||
}
|
||||
return `ol...${this.last4}`;
|
||||
}
|
||||
|
||||
@computed
|
||||
get searchContent(): string[] {
|
||||
return [this.name, this.obfuscatedValue].filter(Boolean);
|
||||
}
|
||||
|
||||
@computed
|
||||
get searchSuppressed(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiKey;
|
||||
|
||||
@@ -45,7 +45,7 @@ function APIAndAccess() {
|
||||
}
|
||||
>
|
||||
<Heading>{t("API & Access")}</Heading>
|
||||
<h2>{t("API keys")}</h2>
|
||||
<h2>{t("Personal keys")}</h2>
|
||||
{can.createApiKey ? (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
|
||||
+101
-10
@@ -1,24 +1,102 @@
|
||||
import type { ColumnSort } from "@tanstack/react-table";
|
||||
import { observer } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import type ApiKey from "~/models/ApiKey";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import { ConditionalFade } from "~/components/Fade";
|
||||
import Heading from "~/components/Heading";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ApiKeyListItem from "./components/ApiKeyListItem";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import { ApiKeysTable } from "./components/ApiKeysTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
|
||||
function ApiKeys() {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const params = useQuery();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const [query, setQuery] = useState(params.get("query") || "");
|
||||
|
||||
const reqParams = useMemo(
|
||||
() => ({
|
||||
query: params.get("query") || undefined,
|
||||
sort: params.get("sort") || "createdAt",
|
||||
direction: (params.get("direction") || "desc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC",
|
||||
}),
|
||||
[params]
|
||||
);
|
||||
|
||||
const sort: ColumnSort = useMemo(
|
||||
() => ({
|
||||
id: reqParams.sort,
|
||||
desc: reqParams.direction === "DESC",
|
||||
}),
|
||||
[reqParams.sort, reqParams.direction]
|
||||
);
|
||||
|
||||
const orderedData = apiKeys.orderedData;
|
||||
const filteredApiKeys = useMemo(
|
||||
() =>
|
||||
reqParams.query ? apiKeys.findByQuery(reqParams.query) : orderedData,
|
||||
[apiKeys, orderedData, reqParams.query]
|
||||
);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: filteredApiKeys,
|
||||
sort,
|
||||
reqFn: apiKeys.fetchPage,
|
||||
reqParams,
|
||||
});
|
||||
|
||||
const updateParams = useCallback(
|
||||
(name: string, value: string) => {
|
||||
if (value) {
|
||||
params.set(name, value);
|
||||
} else {
|
||||
params.delete(name);
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(t("Could not load API keys"));
|
||||
}
|
||||
}, [t, error]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => updateParams("query", query), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, updateParams]);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -37,6 +115,7 @@ function ApiKeys() {
|
||||
)}
|
||||
</>
|
||||
}
|
||||
wide
|
||||
>
|
||||
<Heading>{t("API Keys")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
@@ -54,13 +133,25 @@ function ApiKeys() {
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<PaginatedList<ApiKey>
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.orderedData}
|
||||
renderItem={(apiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
/>
|
||||
<StickyFilters>
|
||||
<InputSearch
|
||||
short
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<ConditionalFade animate={!data}>
|
||||
<ApiKeysTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
</ConditionalFade>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import type ApiKey from "~/models/ApiKey";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import { ContextMenu } from "~/components/Menu/ContextMenu";
|
||||
import {
|
||||
type Props as TableProps,
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { useApiKeyMenuActions } from "~/hooks/useApiKeyMenuActions";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import ApiKeyMenu from "~/menus/ApiKeyMenu";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
import { dateToExpiry } from "~/utils/date";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
import { CopyIcon } from "outline-icons";
|
||||
|
||||
const ROW_HEIGHT = 50;
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
|
||||
type Props = Omit<TableProps<ApiKey>, "columns" | "rowHeight">;
|
||||
|
||||
const ApiKeyRowContextMenu = observer(function ApiKeyRowContextMenu({
|
||||
apiKey,
|
||||
menuLabel,
|
||||
children,
|
||||
}: {
|
||||
apiKey: ApiKey;
|
||||
menuLabel: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const action = useApiKeyMenuActions(apiKey);
|
||||
return (
|
||||
<ContextMenu action={action} ariaLabel={menuLabel}>
|
||||
{children}
|
||||
</ContextMenu>
|
||||
);
|
||||
});
|
||||
|
||||
export const ApiKeysTable = observer(function ApiKeysTable(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const userLocale = useUserLocale();
|
||||
|
||||
const applyContextMenu = useCallback(
|
||||
(apiKey: ApiKey, rowElement: React.ReactNode) => (
|
||||
<ApiKeyRowContextMenu apiKey={apiKey} menuLabel={t("API key")}>
|
||||
{rowElement}
|
||||
</ApiKeyRowContextMenu>
|
||||
),
|
||||
[t]
|
||||
);
|
||||
|
||||
const columns = useMemo<TableColumn<ApiKey>[]>(
|
||||
() => [
|
||||
{
|
||||
type: "data",
|
||||
id: "name",
|
||||
header: t("Name"),
|
||||
accessor: (apiKey) => apiKey.name,
|
||||
component: (apiKey) => (
|
||||
<HStack spacing={4} wrap>
|
||||
<Text selectable>{apiKey.name}</Text>
|
||||
{apiKey.scope && (
|
||||
<Tooltip
|
||||
content={apiKey.scope.map((s) => (
|
||||
<span key={s}>
|
||||
{s}
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
>
|
||||
<Badge>{t("Restricted scope")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
),
|
||||
width: "3fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "value",
|
||||
header: t("Key"),
|
||||
sortable: false,
|
||||
accessor: (apiKey) => apiKey.obfuscatedValue,
|
||||
component: (apiKey) =>
|
||||
apiKey.value ? (
|
||||
<CopyToClipboard
|
||||
text={apiKey.value}
|
||||
onCopy={() => toast.success(t("API key copied"))}
|
||||
>
|
||||
<CopyableText type="tertiary" as="div" monospace selectable>
|
||||
<CopyIcon />
|
||||
{apiKey.value}
|
||||
</CopyableText>
|
||||
</CopyToClipboard>
|
||||
) : (
|
||||
<Text type="tertiary" monospace>
|
||||
{apiKey.obfuscatedValue}
|
||||
</Text>
|
||||
),
|
||||
width: "2.5fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "userId",
|
||||
header: t("Created by"),
|
||||
accessor: (apiKey) => apiKey.user?.name,
|
||||
component: (apiKey) =>
|
||||
apiKey.user ? (
|
||||
<HStack>
|
||||
<Avatar model={apiKey.user} size={AvatarSize.Medium} />
|
||||
<Text selectable>{apiKey.user.name}</Text>
|
||||
</HStack>
|
||||
) : null,
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "lastActiveAt",
|
||||
header: t("Last used"),
|
||||
accessor: (apiKey) => apiKey.lastActiveAt,
|
||||
component: (apiKey) =>
|
||||
apiKey.lastActiveAt ? (
|
||||
<Time dateTime={apiKey.lastActiveAt} addSuffix shorten />
|
||||
) : (
|
||||
<Text type="tertiary">{t("Never")}</Text>
|
||||
),
|
||||
width: "1.5fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "expiresAt",
|
||||
header: t("Expires"),
|
||||
accessor: (apiKey) => apiKey.expiresAt,
|
||||
component: (apiKey) =>
|
||||
apiKey.isExpired ? (
|
||||
<Text type="danger">
|
||||
{t("Expired")} <Time dateTime={apiKey.expiresAt!} addSuffix />
|
||||
</Text>
|
||||
) : apiKey.expiresAt ? (
|
||||
<Text type="tertiary">
|
||||
{dateToExpiry(apiKey.expiresAt, t, userLocale)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="tertiary">{t("No expiry")}</Text>
|
||||
),
|
||||
width: "1.5fr",
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
id: "action",
|
||||
component: (apiKey) => <ApiKeyMenu apiKey={apiKey} />,
|
||||
width: "50px",
|
||||
},
|
||||
],
|
||||
[t, userLocale]
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableTable
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={STICKY_OFFSET}
|
||||
decorateRow={applyContextMenu}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const CopyableText = styled(Text)`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { WebhooksIcon } from "outline-icons";
|
||||
import { PlusIcon, WebhooksIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import type WebhookSubscription from "~/models/WebhookSubscription";
|
||||
@@ -35,10 +35,12 @@ function Webhooks() {
|
||||
{can.createWebhookSubscription && (
|
||||
<Action>
|
||||
<Button
|
||||
type="submit"
|
||||
value={`${t("New webhook")}…`}
|
||||
type="button"
|
||||
onClick={handleNewModalOpen}
|
||||
/>
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{`${t("New webhook")}…`}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -53,7 +53,7 @@ router.post(
|
||||
pagination(),
|
||||
validate(T.APIKeysListSchema),
|
||||
async (ctx: APIContext<T.APIKeysListReq>) => {
|
||||
const { userId } = ctx.input.body;
|
||||
const { userId, sort, direction } = ctx.input.body;
|
||||
const { pagination } = ctx.state;
|
||||
const actor = ctx.state.auth.user;
|
||||
|
||||
@@ -86,7 +86,7 @@ router.post(
|
||||
where,
|
||||
},
|
||||
],
|
||||
order: [["createdAt", "DESC"]],
|
||||
order: [[sort, direction]],
|
||||
offset: pagination.offset,
|
||||
limit: pagination.limit,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { ApiKey } from "@server/models";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
import { ApiKeyValidation } from "@shared/validations";
|
||||
|
||||
@@ -23,6 +24,20 @@ export const APIKeysListSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** The owner of the API key */
|
||||
userId: z.uuid().optional(),
|
||||
|
||||
/** API keys sorting direction */
|
||||
direction: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val !== "ASC" ? "DESC" : val)),
|
||||
|
||||
/** API keys sorting column */
|
||||
sort: z
|
||||
.string()
|
||||
.refine((val) => Object.keys(ApiKey.getAttributes()).includes(val), {
|
||||
error: "Invalid sort parameter",
|
||||
})
|
||||
.prefault("createdAt"),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"New API key": "New API key",
|
||||
"Copy": "Copy",
|
||||
"API key copied": "API key copied",
|
||||
"Delete": "Delete",
|
||||
"Revoke": "Revoke",
|
||||
"Revoke API key": "Revoke API key",
|
||||
@@ -76,7 +78,6 @@
|
||||
"Copy public link": "Copy public link",
|
||||
"Link copied to clipboard": "Link copied to clipboard",
|
||||
"Copy link": "Copy link",
|
||||
"Copy": "Copy",
|
||||
"Duplicate": "Duplicate",
|
||||
"Duplicate document": "Duplicate document",
|
||||
"Copy document": "Copy document",
|
||||
@@ -1068,10 +1069,12 @@
|
||||
"Search titles only": "Search titles only",
|
||||
"Please try again or contact support if the problem persists": "Please try again or contact support if the problem persists",
|
||||
"No documents found for your search filters.": "No documents found for your search filters.",
|
||||
"Personal keys": "Personal keys",
|
||||
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
|
||||
"API keys have been disabled by an admin for your account": "API keys have been disabled by an admin for your account",
|
||||
"Application access": "Application access",
|
||||
"Manage which third-party and internal applications have been granted access to your {{ appName }} account.": "Manage which third-party and internal applications have been granted access to your {{ appName }} account.",
|
||||
"Could not load API keys": "Could not load API keys",
|
||||
"API": "API",
|
||||
"API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
|
||||
"Application published": "Application published",
|
||||
@@ -1131,6 +1134,10 @@
|
||||
"API key copied to clipboard": "API key copied to clipboard",
|
||||
"Copied": "Copied",
|
||||
"Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?",
|
||||
"Key": "Key",
|
||||
"Created by": "Created by",
|
||||
"Never": "Never",
|
||||
"Expires": "Expires",
|
||||
"Disconnect integration": "Disconnect integration",
|
||||
"Disconnecting": "Disconnecting",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Are you sure you want to disconnect the <em>{{ service }}</em> integration?",
|
||||
|
||||
Reference in New Issue
Block a user