mirror of
https://github.com/outline/outline.git
synced 2026-04-24 11:20:11 -05:00
Add screen for API key management (#8049)
* API keys * Add api key list for admins * permissions
This commit is contained in:
@@ -29,6 +29,7 @@ import useCurrentUser from "./useCurrentUser";
|
||||
import usePolicy from "./usePolicy";
|
||||
|
||||
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
|
||||
const PersonalApiKeys = lazy(() => import("~/scenes/Settings/PersonalApiKeys"));
|
||||
const Details = lazy(() => import("~/scenes/Settings/Details"));
|
||||
const Export = lazy(() => import("~/scenes/Settings/Export"));
|
||||
const Features = lazy(() => import("~/scenes/Settings/Features"));
|
||||
@@ -87,10 +88,10 @@ const useSettingsConfig = () => {
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: t("API"),
|
||||
path: settingsPath("tokens"),
|
||||
component: ApiKeys,
|
||||
enabled: can.createApiKey,
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("personal-api-keys"),
|
||||
component: PersonalApiKeys,
|
||||
enabled: can.createApiKey && !can.listApiKeys,
|
||||
group: t("Account"),
|
||||
icon: CodeIcon,
|
||||
},
|
||||
@@ -143,6 +144,14 @@ const useSettingsConfig = () => {
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
},
|
||||
{
|
||||
name: t("API Keys"),
|
||||
path: settingsPath("api-keys"),
|
||||
component: ApiKeys,
|
||||
enabled: can.listApiKeys,
|
||||
group: t("Workspace"),
|
||||
icon: CodeIcon,
|
||||
},
|
||||
{
|
||||
name: t("Shared Links"),
|
||||
path: settingsPath("shares"),
|
||||
|
||||
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
@@ -13,36 +12,17 @@ import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ApiKeyListItem from "./components/ApiKeyListItem";
|
||||
|
||||
function ApiKeys() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
|
||||
const [copiedKeyId, setCopiedKeyId] = React.useState<string | null>();
|
||||
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopy = React.useCallback(
|
||||
(keyId: string) => {
|
||||
if (copyTimeoutIdRef.current) {
|
||||
clearTimeout(copyTimeoutIdRef.current);
|
||||
}
|
||||
setCopiedKeyId(keyId);
|
||||
copyTimeoutIdRef.current = setTimeout(() => {
|
||||
setCopiedKeyId(null);
|
||||
}, 3000);
|
||||
toast.message(t("API key copied to clipboard"));
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API")}
|
||||
@@ -62,12 +42,11 @@ function ApiKeys() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API")}</Heading>
|
||||
<Heading>{t("API Keys")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Create personal API keys to authenticate with the API and programatically control
|
||||
your workspace's data. API keys have the same permissions as your user account.
|
||||
For more details see the <em>developer documentation</em>."
|
||||
defaults="API keys can be used to authenticate with the API and programatically control
|
||||
your workspace's data. For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a
|
||||
@@ -81,16 +60,10 @@ function ApiKeys() {
|
||||
</Text>
|
||||
<PaginatedList
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
heading={<h2>{t("Personal keys")}</h2>}
|
||||
items={apiKeys.orderedData}
|
||||
heading={<h2>{t("All")}</h2>}
|
||||
renderItem={(apiKey: ApiKey) => (
|
||||
<ApiKeyListItem
|
||||
key={apiKey.id}
|
||||
apiKey={apiKey}
|
||||
isCopied={apiKey.id === copiedKeyId}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
/>
|
||||
</Scene>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ApiKeyListItem from "./components/ApiKeyListItem";
|
||||
|
||||
function PersonalApiKeys() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const can = usePolicy(team);
|
||||
const context = useActionContext();
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("API")}
|
||||
icon={<CodeIcon />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
<Action>
|
||||
<Button
|
||||
type="submit"
|
||||
value={`${t("New API key")}…`}
|
||||
action={createApiKey}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Create personal API keys to authenticate with the API and programatically control
|
||||
your workspace's data. API keys have the same permissions as your user account.
|
||||
For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a
|
||||
href="https://www.getoutline.com/developers"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<PaginatedList
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
heading={<h2>{t("Personal keys")}</h2>}
|
||||
renderItem={(apiKey: ApiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(PersonalApiKeys);
|
||||
@@ -1,6 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CopyIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import Button from "~/components/Button";
|
||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||
@@ -8,24 +10,29 @@ import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import ApiKeyMenu from "~/menus/ApiKeyMenu";
|
||||
import { dateToExpiry } from "~/utils/date";
|
||||
|
||||
type Props = {
|
||||
/** The API key to display */
|
||||
apiKey: ApiKey;
|
||||
isCopied: boolean;
|
||||
onCopy: (keyId: string) => void;
|
||||
};
|
||||
|
||||
const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const userLocale = useUserLocale();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const subtitle = (
|
||||
<>
|
||||
<Text type="tertiary">
|
||||
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix /> ·{" "}
|
||||
{t(`Created`)} <Time dateTime={apiKey.createdAt} addSuffix />{" "}
|
||||
{apiKey.userId === user.id
|
||||
? ""
|
||||
: t(`by {{ name }}`, { name: user.name })}{" "}
|
||||
·{" "}
|
||||
</Text>
|
||||
{apiKey.lastActiveAt && (
|
||||
<Text type={"tertiary"}>
|
||||
@@ -41,9 +48,19 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
</>
|
||||
);
|
||||
|
||||
const [copied, setCopied] = React.useState<boolean>(false);
|
||||
const copyTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
onCopy(apiKey.id);
|
||||
}, [apiKey.id, onCopy]);
|
||||
if (copyTimeoutIdRef.current) {
|
||||
clearTimeout(copyTimeoutIdRef.current);
|
||||
}
|
||||
setCopied(true);
|
||||
copyTimeoutIdRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
toast.message(t("API key copied to clipboard"));
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
@@ -52,10 +69,10 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
{apiKey.value && (
|
||||
{apiKey.value && handleCopy && (
|
||||
<CopyToClipboard text={apiKey.value} onCopy={handleCopy}>
|
||||
<Button type="button" icon={<CopyIcon />} neutral borderOnHover>
|
||||
{isCopied ? t("Copied") : t("Copy")}
|
||||
{copied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
)}
|
||||
@@ -74,4 +91,4 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyListItem;
|
||||
export default observer(ApiKeyListItem);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { ApiKey, User, Team } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import { and, isOwner, isTeamModel, isTeamMutable } from "./utils";
|
||||
import {
|
||||
and,
|
||||
isCloudHosted,
|
||||
isOwner,
|
||||
isTeamModel,
|
||||
isTeamMutable,
|
||||
} from "./utils";
|
||||
|
||||
allow(User, "createApiKey", Team, (actor, team) =>
|
||||
and(
|
||||
@@ -18,6 +24,7 @@ allow(User, "createApiKey", Team, (actor, team) =>
|
||||
allow(User, "listApiKeys", Team, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
isCloudHosted(),
|
||||
isTeamModel(actor, team),
|
||||
actor.isAdmin
|
||||
)
|
||||
|
||||
@@ -497,7 +497,7 @@
|
||||
"Could not import file": "Could not import file",
|
||||
"Unsubscribed from document": "Unsubscribed from document",
|
||||
"Account": "Account",
|
||||
"API": "API",
|
||||
"API Keys": "API Keys",
|
||||
"Details": "Details",
|
||||
"Security": "Security",
|
||||
"Features": "Features",
|
||||
@@ -829,11 +829,12 @@
|
||||
"Something went wrong": "Something went wrong",
|
||||
"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.",
|
||||
"API key copied to clipboard": "API key copied to clipboard",
|
||||
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n 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. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
||||
"Personal keys": "Personal 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>.",
|
||||
"by {{ name }}": "by {{ name }}",
|
||||
"Last used": "Last used",
|
||||
"No expiry": "No expiry",
|
||||
"API key copied to clipboard": "API key copied to clipboard",
|
||||
"Copied": "Copied",
|
||||
"Revoking": "Revoking",
|
||||
"Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?",
|
||||
@@ -958,6 +959,8 @@
|
||||
"Email address": "Email address",
|
||||
"Your email address should be updated in your SSO provider.": "Your email address should be updated in your SSO provider.",
|
||||
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.",
|
||||
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n 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. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
||||
"Personal keys": "Personal keys",
|
||||
"Preferences saved": "Preferences saved",
|
||||
"Delete account": "Delete account",
|
||||
"Manage settings that affect your personal experience.": "Manage settings that affect your personal experience.",
|
||||
|
||||
Reference in New Issue
Block a user