feat: add API key expiry options (#7064)

* feat: add API key expiry options

* review
This commit is contained in:
Hemachandar
2024-06-19 07:04:45 +05:30
committed by GitHub
parent c04bedef4c
commit 3af9861c4a
20 changed files with 465 additions and 100 deletions

View File

@@ -1,7 +1,7 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import APIKeyNew from "~/scenes/APIKeyNew";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
@@ -19,7 +19,7 @@ export const createApiKey = createAction({
stores.dialogs.openModal({
title: t("New API key"),
content: <APIKeyNew onSubmit={stores.dialogs.closeAllModals} />,
content: <ApiKeyNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});

View File

@@ -50,6 +50,11 @@ export type Props = {
note?: React.ReactNode;
onChange?: (value: string | null) => void;
style?: React.CSSProperties;
/**
* Set to true if this component is rendered inside a Modal.
* The Modal will take care of preventing body scroll behaviour.
*/
skipBodyScroll?: boolean;
};
export interface InputSelectRef {
@@ -79,6 +84,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
note,
icon,
nude,
skipBodyScroll,
...rest
} = props;
@@ -91,7 +97,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
const popover = useSelectPopover({
...select,
hideOnClickOutside: false,
preventBodyScroll: true,
preventBodyScroll: skipBodyScroll ? false : true,
disabled,
});
@@ -220,7 +226,12 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
</StyledButton>
)}
</Select>
<SelectPopover {...select} {...popover} aria-label={ariaLabel}>
<SelectPopover
{...select}
{...popover}
aria-label={ariaLabel}
preventBodyScroll={skipBodyScroll ? false : true}
>
{(popoverProps: InnerProps) => {
const topAnchor = popoverProps.style?.top === "0";
const rightAnchor = popoverProps.placement === "bottom-end";

View File

@@ -13,6 +13,10 @@ class ApiKey extends Model {
@observable
name: string;
@Field
@observable
expiresAt?: string;
secret: string;
}

View File

@@ -1,74 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ApiKeyValidation } from "@shared/validations";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
type Props = {
onSubmit: () => void;
};
function APIKeyNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [isSaving, setIsSaving] = React.useState(false);
const { apiKeys } = useStores();
const { t } = useTranslation();
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
await apiKeys.create({
name,
});
toast.success(t("API Key created"));
onSubmit();
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
},
[t, name, onSubmit, apiKeys]
);
const handleNameChange = React.useCallback((event) => {
setName(event.target.value);
}, []);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{t(
`Name your key something that will help you to remember it's use in the future, for example "local development" or "continuous integration".`
)}
</Text>
<Flex>
<Input
type="text"
label={t("Name")}
onChange={handleNameChange}
value={name}
minLength={ApiKeyValidation.minNameLength}
maxLength={ApiKeyValidation.maxNameLength}
required
autoFocus
flex
/>
</Flex>
<Flex justify="flex-end">
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Creating")}` : t("Create")}
</Button>
</Flex>
</form>
);
}
export default APIKeyNew;

View File

@@ -0,0 +1,100 @@
import { format as formatDate } from "date-fns";
import { CalendarIcon } from "outline-icons";
import React from "react";
import { DayPicker } from "react-day-picker";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit";
import styled, { useTheme } from "styled-components";
import { dateLocale } from "@shared/utils/date";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import useUserLocale from "~/hooks/useUserLocale";
type Props = {
selectedDate?: Date;
onSelect: (date: Date) => void;
};
const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
const { t } = useTranslation();
const theme = useTheme();
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const popover = usePopoverState({ gutter: 0, placement: "right" });
const popoverContentRef = React.useRef<HTMLDivElement>(null);
const styles = React.useMemo(
() =>
({
"--rdp-caption-font-size": "16px",
"--rdp-cell-size": "34px",
"--rdp-selected-text": theme.accentText,
"--rdp-accent-color": theme.accent,
"--rdp-accent-color-dark": theme.accent,
"--rdp-background-color": theme.listItemHoverBackground,
"--rdp-background-color-dark": theme.listItemHoverBackground,
} as React.CSSProperties),
[theme]
);
const handleSelect = React.useCallback(
(date: Date) => {
popover.hide();
onSelect(date);
},
[popover, onSelect]
);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<StyledPopoverButton {...props} icon={<Icon />} neutral>
{selectedDate
? formatDate(selectedDate, "MMM dd, yyyy", { locale })
: t("Choose a date")}
</StyledPopoverButton>
)}
</PopoverDisclosure>
<Popover
{...popover}
ref={popoverContentRef}
width={280}
shrink
aria-label={t("Choose a date")}
>
<DayPicker
required
mode="single"
selected={selectedDate}
onSelect={handleSelect}
style={styles}
disabled={{ before: new Date() }}
/>
</Popover>
</>
);
};
const Icon = () => (
<IconWrapper>
<CalendarIcon />
</IconWrapper>
);
const StyledPopoverButton = styled(Button)`
margin-top: 12px;
width: 150px;
`;
const IconWrapper = styled.span`
display: flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
`;
export default ExpiryDatePicker;

View File

@@ -0,0 +1,145 @@
import { endOfDay } from "date-fns";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { ApiKeyValidation } from "@shared/validations";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import InputSelect, { Option } from "~/components/InputSelect";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { dateToExpiry } from "~/utils/date";
import "react-day-picker/dist/style.css";
import ExpiryDatePicker from "./components/ExpiryDatePicker";
import { ExpiryType, ExpiryValues, calculateExpiryDate } from "./utils";
type Props = {
onSubmit: () => void;
};
function ApiKeyNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [expiryType, setExpiryType] = React.useState<ExpiryType>(
ExpiryType.Week
);
const currentDate = React.useRef<Date>(new Date());
const [expiresAt, setExpiresAt] = React.useState<Date | undefined>(() =>
calculateExpiryDate(currentDate.current, expiryType)
);
const [isSaving, setIsSaving] = React.useState(false);
const { apiKeys } = useStores();
const { t } = useTranslation();
const userLocale = useUserLocale();
const submitDisabled =
isSaving || !name || (!expiresAt && expiryType !== ExpiryType.NoExpiration);
const expiryOptions = React.useMemo<Option[]>(
() =>
[...ExpiryValues.entries()].map(([expType, { label }]) => ({
label,
value: expType,
})),
[]
);
const handleNameChange = React.useCallback((event) => {
setName(event.target.value);
}, []);
const handleExpiryTypeChange = React.useCallback((value: string) => {
const expiry = value as ExpiryType;
setExpiryType(expiry);
setExpiresAt(calculateExpiryDate(currentDate.current, expiry));
}, []);
const handleSelectCustomDate = React.useCallback((date: Date) => {
setExpiresAt(endOfDay(date));
}, []);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
await apiKeys.create({
name,
expiresAt: expiresAt?.toISOString(),
});
toast.success(t("API key created"));
onSubmit();
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
},
[t, name, expiresAt, onSubmit, apiKeys]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{t(
`Name your key something that will help you to remember it's use in the future, for example "local development" or "continuous integration".`
)}
</Text>
<Flex column>
<Input
type="text"
label={t("Name")}
onChange={handleNameChange}
value={name}
minLength={ApiKeyValidation.minNameLength}
maxLength={ApiKeyValidation.maxNameLength}
required
autoFocus
flex
/>
<Flex align="center" gap={16}>
<StyledExpirySelect
ariaLabel={t("Expiration")}
label={t("Expiration")}
value={expiryType}
options={expiryOptions}
onChange={handleExpiryTypeChange}
skipBodyScroll={true}
/>
{expiryType === ExpiryType.Custom ? (
<ExpiryDatePicker
selectedDate={expiresAt}
onSelect={handleSelectCustomDate}
/>
) : (
<StyledExpiryText type="secondary" size="small">
{expiresAt
? `${dateToExpiry(expiresAt.toString(), t, userLocale)}.`
: `${t("Never expires")}.`}
</StyledExpiryText>
)}
</Flex>
</Flex>
<Flex justify="flex-end">
<Button type="submit" disabled={submitDisabled}>
{isSaving ? `${t("Creating")}` : t("Create")}
</Button>
</Flex>
</form>
);
}
const StyledExpirySelect = styled(InputSelect)`
width: 150px;
`;
const StyledExpiryText = styled(Text)`
position: relative;
top: 4px;
`;
export default ApiKeyNew;

View File

@@ -0,0 +1,37 @@
import { addDays, endOfDay } from "date-fns";
import i18next from "i18next";
export enum ExpiryType {
Week = "7 days",
Month = "30 days",
TwoMonths = "60 days",
ThreeMonths = "90 days",
Custom = "Custom",
NoExpiration = "No expiration",
}
type ExpiryValue = {
label: string;
value?: number;
};
export const ExpiryValues: Map<ExpiryType, ExpiryValue> = new Map([
[ExpiryType.Week, { label: i18next.t("7 days"), value: 7 }],
[ExpiryType.Month, { label: i18next.t("30 days"), value: 30 }],
[ExpiryType.TwoMonths, { label: i18next.t("60 days"), value: 60 }],
[ExpiryType.ThreeMonths, { label: i18next.t("90 days"), value: 90 }],
[ExpiryType.Custom, { label: i18next.t("Custom") }],
[ExpiryType.NoExpiration, { label: i18next.t("No expiration") }],
]);
export const calculateExpiryDate = (
currentDate: Date,
expiryType: ExpiryType
): Date | undefined => {
const daysToAdd = ExpiryValues.get(expiryType)?.value;
if (!daysToAdd) {
return;
}
const expiryDate = addDays(currentDate, daysToAdd);
return endOfDay(expiryDate);
};

View File

@@ -2,6 +2,7 @@ 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";
@@ -23,6 +24,23 @@ function ApiKeys() {
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 Keys")}
@@ -62,9 +80,14 @@ function ApiKeys() {
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
heading={<h2>{t("Active")}</h2>}
heading={<h2>{t("Generated Keys")}</h2>}
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
<ApiKeyListItem
key={apiKey.id}
apiKey={apiKey}
isCopied={apiKey.id === copiedKeyId}
onCopy={handleCopy}
/>
)}
/>
</Scene>

View File

@@ -1,45 +1,53 @@
import { isPast } from "date-fns";
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";
import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Text from "~/components/Text";
import useUserLocale from "~/hooks/useUserLocale";
import ApiKeyMenu from "~/menus/ApiKeyMenu";
import { dateToExpiry } from "~/utils/date";
type Props = {
apiKey: ApiKey;
isCopied: boolean;
onCopy: (keyId: string) => void;
};
const ApiKeyListItem = ({ apiKey }: Props) => {
const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
const { t } = useTranslation();
const [linkCopied, setLinkCopied] = React.useState<boolean>(false);
const userLocale = useUserLocale();
React.useEffect(() => {
if (linkCopied) {
setTimeout(() => {
setLinkCopied(false);
}, 3000);
}
}, [linkCopied]);
const hasExpired = apiKey.expiresAt
? isPast(new Date(apiKey.expiresAt))
: false;
const subtitle = (
<Text type={hasExpired ? "danger" : "tertiary"}>
{apiKey.expiresAt
? dateToExpiry(apiKey.expiresAt, t, userLocale)
: t("No expiry")}
</Text>
);
const handleCopy = React.useCallback(() => {
setLinkCopied(true);
toast.message(t("API token copied to clipboard"));
}, [t]);
onCopy(apiKey.id);
}, [apiKey.id, onCopy]);
return (
<ListItem
key={apiKey.id}
title={apiKey.name}
subtitle={<code>{apiKey.secret.slice(0, 15)}</code>}
subtitle={subtitle}
actions={
<Flex align="center" gap={8}>
<CopyToClipboard text={apiKey.secret} onCopy={handleCopy}>
<Button type="button" icon={<CopyIcon />} neutral borderOnHover>
{linkCopied ? t("Copied") : t("Copy")}
{isCopied ? t("Copied") : t("Copy")}
</Button>
</CopyToClipboard>
<ApiKeyMenu apiKey={apiKey} />

View File

@@ -5,6 +5,9 @@ import {
differenceInCalendarMonths,
differenceInCalendarYears,
format as formatDate,
isTomorrow,
isSameWeek,
isPast,
} from "date-fns";
import { TFunction } from "i18next";
import startCase from "lodash/startCase";
@@ -71,6 +74,42 @@ export function dateToHeading(
});
}
export function dateToExpiry(
dateTime: string,
t: TFunction,
userLocale: string | null | undefined
) {
const date = Date.parse(dateTime);
const now = new Date();
const locale = dateLocale(userLocale);
if (isYesterday(date)) {
return t("Expired Yesterday");
}
if (isPast(date)) {
return `${t("Expired on")} ${formatDate(date, "MMM dd, yyyy", { locale })}`;
}
if (isToday(date)) {
return t("Expires Today");
}
if (isTomorrow(date)) {
return t("Expires Tomorrow");
}
const prefix = t("Expires on");
if (isSameWeek(date, now)) {
return `${prefix} ${formatDate(Date.parse(dateTime), "iiii", {
locale,
})}`;
}
return `${prefix} ${formatDate(date, "MMM dd, yyyy", { locale })}`;
}
/**
* Replaces template variables in the given text with the current date and time.
*

View File

@@ -191,6 +191,7 @@
"react": "^17.0.2",
"react-avatar-editor": "^13.0.2",
"react-color": "^2.17.3",
"react-day-picker": "^8.10.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^17.0.2",

View File

@@ -83,6 +83,10 @@ export default function auth(options: AuthenticationOptions = {}) {
throw AuthenticationError("Invalid API key");
}
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
throw AuthenticationError("Invalid API key");
}
user = await User.findByPk(apiKey.userId, {
include: [
{

View File

@@ -0,0 +1,15 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("apiKeys", "expiresAt", {
type: Sequelize.DATE,
allowNull: true,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn("apiKeys", "expiresAt");
},
};

View File

@@ -7,6 +7,7 @@ import {
BeforeValidate,
BelongsTo,
ForeignKey,
IsDate,
} from "sequelize-typescript";
import { ApiKeyValidation } from "@shared/validations";
import User from "./User";
@@ -34,6 +35,10 @@ class ApiKey extends ParanoidModel<
@Column
secret: string;
@IsDate
@Column
expiresAt: Date | null;
// hooks
@BeforeValidate

View File

@@ -7,5 +7,6 @@ export default function presentApiKey(key: ApiKey) {
secret: key.secret,
createdAt: key.createdAt,
updatedAt: key.updatedAt,
expiresAt: key.expiresAt,
};
}

View File

@@ -4,7 +4,25 @@ import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#apiKeys.create", () => {
it("should allow creating an api key", async () => {
it("should allow creating an api key with expiry", async () => {
const now = new Date();
const user = await buildUser();
const res = await server.post("/api/apiKeys.create", {
body: {
token: user.getJwtToken(),
name: "My API Key",
expiresAt: now.toISOString(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("My API Key");
expect(body.data.expiresAt).toEqual(now.toISOString());
});
it("should allow creating an api key without expiry", async () => {
const user = await buildUser();
const res = await server.post("/api/apiKeys.create", {
@@ -17,6 +35,7 @@ describe("#apiKeys.create", () => {
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("My API Key");
expect(body.data.expiresAt).toBeNull();
});
it("should require authentication", async () => {
@@ -27,10 +46,12 @@ describe("#apiKeys.create", () => {
describe("#apiKeys.list", () => {
it("should return api keys of a user", async () => {
const now = new Date();
const user = await buildUser();
await buildApiKey({
name: "My API Key",
userId: user.id,
expiresAt: now,
});
const res = await server.post("/api/apiKeys.list", {
@@ -42,6 +63,7 @@ describe("#apiKeys.list", () => {
expect(res.status).toEqual(200);
expect(body.data[0].name).toEqual("My API Key");
expect(body.data[0].expiresAt).toEqual(now.toISOString());
});
it("should require authentication", async () => {

View File

@@ -18,7 +18,7 @@ router.post(
validate(T.APIKeysCreateSchema),
transaction(),
async (ctx: APIContext<T.APIKeysCreateReq>) => {
const { name } = ctx.input.body;
const { name, expiresAt } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
@@ -27,6 +27,7 @@ router.post(
{
name,
userId: user.id,
expiresAt,
},
{ transaction }
);

View File

@@ -5,6 +5,8 @@ export const APIKeysCreateSchema = BaseSchema.extend({
body: z.object({
/** API Key name */
name: z.string(),
/** API Key expiry date */
expiresAt: z.coerce.date().optional(),
}),
});

View File

@@ -495,8 +495,17 @@
"left a comment on": "left a comment on",
"shared": "shared",
"invited you to": "invited you to",
"API Key created": "API Key created",
"Choose a date": "Choose a date",
"API key created": "API key created",
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".",
"Expiration": "Expiration",
"Never expires": "Never expires",
"7 days": "7 days",
"30 days": "30 days",
"60 days": "60 days",
"90 days": "90 days",
"Custom": "Custom",
"No expiration": "No expiration",
"The document archive is empty at the moment.": "The document archive is empty at the moment.",
"Collection menu": "Collection menu",
"Drop documents to import": "Drop documents to import",
@@ -756,10 +765,11 @@
"Search titles only": "Search titles only",
"No documents found for your search filters.": "No documents found for your search filters.",
"Search Results": "Search Results",
"API key copied to clipboard": "API key copied to clipboard",
"API Keys": "API Keys",
"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>.",
"Active": "Active",
"API token copied to clipboard": "API token copied to clipboard",
"Generated Keys": "Generated Keys",
"No expiry": "No expiry",
"Copied": "Copied",
"Revoking": "Revoking",
"Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?",
@@ -808,6 +818,7 @@
"Admins": "Admins",
"Editors": "Editors",
"All status": "All status",
"Active": "Active",
"Settings saved": "Settings saved",
"Logo updated": "Logo updated",
"Unable to upload new logo": "Unable to upload new logo",
@@ -959,6 +970,11 @@
"This month": "This month",
"Last month": "Last month",
"This year": "This year",
"Expired Yesterday": "Expired Yesterday",
"Expired on": "Expired on",
"Expires Today": "Expires Today",
"Expires Tomorrow": "Expires Tomorrow",
"Expires on": "Expires on",
"Connect": "Connect",
"Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?",
"Something went wrong while authenticating your request. Please try logging in again.": "Something went wrong while authenticating your request. Please try logging in again.",

View File

@@ -13185,6 +13185,11 @@ react-color@^2.17.3:
reactcss "^1.2.0"
tinycolor2 "^1.4.1"
react-day-picker@^8.10.1:
version "8.10.1"
resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.10.1.tgz#4762ec298865919b93ec09ba69621580835b8e80"
integrity sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==
react-dnd-html5-backend@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz#87faef15845d512a23b3c08d29ecfd34871688b6"