feat: init
This commit is contained in:
107
apps/web/core/components/api-token/modal/create-token-modal.tsx
Normal file
107
apps/web/core/components/api-token/modal/create-token-modal.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { mutate } from "swr";
|
||||
// plane imports
|
||||
import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { APITokenService } from "@plane/services";
|
||||
import type { IApiToken } from "@plane/types";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { renderFormattedDate, csvDownload } from "@plane/utils";
|
||||
// constants
|
||||
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
|
||||
// helpers
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// local imports
|
||||
import { CreateApiTokenForm } from "./form";
|
||||
import { GeneratedTokenDetails } from "./generated-token-details";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// services
|
||||
const apiTokenService = new APITokenService();
|
||||
|
||||
export const CreateApiTokenModal: React.FC<Props> = (props) => {
|
||||
const { isOpen, onClose } = props;
|
||||
// states
|
||||
const [neverExpires, setNeverExpires] = useState<boolean>(false);
|
||||
const [generatedToken, setGeneratedToken] = useState<IApiToken | null | undefined>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
|
||||
setTimeout(() => {
|
||||
setNeverExpires(false);
|
||||
setGeneratedToken(null);
|
||||
}, 350);
|
||||
};
|
||||
|
||||
const downloadSecretKey = (data: IApiToken) => {
|
||||
const csvData = {
|
||||
Title: data.label,
|
||||
Description: data.description,
|
||||
Expiry: data.expired_at ? (renderFormattedDate(data.expired_at)?.replace(",", " ") ?? "") : "Never expires",
|
||||
"Secret key": data.token ?? "",
|
||||
};
|
||||
|
||||
csvDownload(csvData, `secret-key-${Date.now()}`);
|
||||
};
|
||||
|
||||
const handleCreateToken = async (data: Partial<IApiToken>) => {
|
||||
// make the request to generate the token
|
||||
await apiTokenService
|
||||
.create(data)
|
||||
.then((res) => {
|
||||
setGeneratedToken(res);
|
||||
downloadSecretKey(res);
|
||||
|
||||
mutate<IApiToken[]>(
|
||||
API_TOKENS_LIST,
|
||||
(prevData) => {
|
||||
if (!prevData) return;
|
||||
|
||||
return [res, ...prevData];
|
||||
},
|
||||
false
|
||||
);
|
||||
captureSuccess({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created,
|
||||
payload: {
|
||||
token: res.id,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err.message || err.detail,
|
||||
});
|
||||
|
||||
captureError({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created,
|
||||
});
|
||||
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={() => {}} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
{generatedToken ? (
|
||||
<GeneratedTokenDetails handleClose={handleClose} tokenDetails={generatedToken} />
|
||||
) : (
|
||||
<CreateApiTokenForm
|
||||
handleClose={handleClose}
|
||||
neverExpires={neverExpires}
|
||||
toggleNeverExpires={() => setNeverExpires((prevData) => !prevData)}
|
||||
onSubmit={handleCreateToken}
|
||||
/>
|
||||
)}
|
||||
</ModalCore>
|
||||
);
|
||||
};
|
||||
256
apps/web/core/components/api-token/modal/form.tsx
Normal file
256
apps/web/core/components/api-token/modal/form.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { add } from "date-fns";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Calendar } from "lucide-react";
|
||||
// types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IApiToken } from "@plane/types";
|
||||
// ui
|
||||
import { CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui";
|
||||
import { cn, renderFormattedDate, renderFormattedTime } from "@plane/utils";
|
||||
// components
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
// helpers
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
neverExpires: boolean;
|
||||
toggleNeverExpires: () => void;
|
||||
onSubmit: (data: Partial<IApiToken>) => Promise<void>;
|
||||
};
|
||||
|
||||
const EXPIRY_DATE_OPTIONS = [
|
||||
{
|
||||
key: "1_week",
|
||||
label: "1 week",
|
||||
value: { weeks: 1 },
|
||||
},
|
||||
{
|
||||
key: "1_month",
|
||||
label: "1 month",
|
||||
value: { months: 1 },
|
||||
},
|
||||
{
|
||||
key: "3_months",
|
||||
label: "3 months",
|
||||
value: { months: 3 },
|
||||
},
|
||||
{
|
||||
key: "1_year",
|
||||
label: "1 year",
|
||||
value: { years: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const defaultValues: Partial<IApiToken> = {
|
||||
label: "",
|
||||
description: "",
|
||||
expired_at: null,
|
||||
};
|
||||
|
||||
const getExpiryDate = (val: string): Date | null | undefined => {
|
||||
const today = new Date();
|
||||
const dateToAdd = EXPIRY_DATE_OPTIONS.find((option) => option.key === val)?.value;
|
||||
if (dateToAdd) return add(today, dateToAdd);
|
||||
return null;
|
||||
};
|
||||
|
||||
const getFormattedDate = (date: Date): Date => {
|
||||
const now = new Date();
|
||||
const hours = now.getHours();
|
||||
const minutes = now.getMinutes();
|
||||
const seconds = now.getSeconds();
|
||||
return add(date, { hours, minutes, seconds });
|
||||
};
|
||||
|
||||
export const CreateApiTokenForm: React.FC<Props> = (props) => {
|
||||
const { handleClose, neverExpires, toggleNeverExpires, onSubmit } = props;
|
||||
// states
|
||||
const [customDate, setCustomDate] = useState<Date | null>(null);
|
||||
// form
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
} = useForm<IApiToken>({ defaultValues });
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleFormSubmit = async (data: IApiToken) => {
|
||||
// if never expires is toggled off, and the user has not selected a custom date or a predefined date, show an error
|
||||
if (!neverExpires && (!data.expired_at || (data.expired_at === "custom" && !customDate)))
|
||||
return setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Please select an expiration date.",
|
||||
});
|
||||
|
||||
const payload: Partial<IApiToken> = {
|
||||
label: data.label,
|
||||
description: data.description,
|
||||
};
|
||||
|
||||
// if never expires is toggled on, set expired_at to null
|
||||
if (neverExpires) payload.expired_at = null;
|
||||
// if never expires is toggled off, and the user has selected a custom date, set expired_at to the custom date
|
||||
else if (data.expired_at === "custom") {
|
||||
payload.expired_at = customDate && getFormattedDate(customDate).toISOString();
|
||||
}
|
||||
// if never expires is toggled off, and the user has selected a predefined date, set expired_at to the predefined date
|
||||
else {
|
||||
const expiryDate = getExpiryDate(data.expired_at ?? "");
|
||||
if (expiryDate) payload.expired_at = expiryDate.toISOString();
|
||||
}
|
||||
|
||||
await onSubmit(payload).then(() => {
|
||||
reset(defaultValues);
|
||||
setCustomDate(null);
|
||||
});
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
const tomorrow = add(today, { days: 1 });
|
||||
const expiredAt = watch("expired_at");
|
||||
const expiryDate = getExpiryDate(expiredAt ?? "");
|
||||
const customDateFormatted = customDate && getFormattedDate(customDate);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="space-y-5 p-5">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">
|
||||
{t("workspace_settings.settings.api_tokens.create_token")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="label"
|
||||
rules={{
|
||||
required: t("title_is_required"),
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: t("title_should_be_less_than_255_characters"),
|
||||
},
|
||||
validate: (val) => val.trim() !== "" || t("title_is_required"),
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.label)}
|
||||
placeholder={t("title")}
|
||||
className="w-full text-base"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.label && <span className="text-xs text-red-500">{errors.label.message}</span>}
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TextArea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={Boolean(errors.description)}
|
||||
placeholder={t("description")}
|
||||
className="w-full text-base resize-none min-h-24"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="expired_at"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const selectedOption = EXPIRY_DATE_OPTIONS.find((option) => option.key === value);
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
customButton={
|
||||
<div
|
||||
className={cn(
|
||||
"h-7 flex items-center gap-2 rounded border-[0.5px] border-custom-border-300 px-2 py-0.5",
|
||||
{
|
||||
"text-custom-text-400": neverExpires,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Calendar className="h-3 w-3" />
|
||||
{value === "custom"
|
||||
? "Custom date"
|
||||
: selectedOption
|
||||
? selectedOption.label
|
||||
: "Set expiration date"}
|
||||
</div>
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={neverExpires}
|
||||
>
|
||||
{EXPIRY_DATE_OPTIONS.map((option) => (
|
||||
<CustomSelect.Option key={option.key} value={option.key}>
|
||||
{option.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
<CustomSelect.Option value="custom">Custom</CustomSelect.Option>
|
||||
</CustomSelect>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{expiredAt === "custom" && (
|
||||
<div className="h-7">
|
||||
<DateDropdown
|
||||
value={customDate}
|
||||
onChange={(date) => setCustomDate(date)}
|
||||
minDate={tomorrow}
|
||||
icon={<Calendar className="h-3 w-3" />}
|
||||
buttonVariant="border-with-text"
|
||||
placeholder="Set date"
|
||||
disabled={neverExpires}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!neverExpires && (
|
||||
<span className="text-xs text-custom-text-400">
|
||||
{expiredAt === "custom"
|
||||
? customDate
|
||||
? `Expires ${renderFormattedDate(customDateFormatted ?? "")} at ${renderFormattedTime(customDateFormatted ?? "")}`
|
||||
: null
|
||||
: expiredAt
|
||||
? `Expires ${renderFormattedDate(expiryDate ?? "")} at ${renderFormattedTime(expiryDate ?? "")}`
|
||||
: null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<div className="flex cursor-pointer items-center gap-1.5" onClick={toggleNeverExpires}>
|
||||
<div className="flex cursor-pointer items-center justify-center">
|
||||
<ToggleSwitch value={neverExpires} onChange={() => {}} size="sm" />
|
||||
</div>
|
||||
<span className="text-xs">{t("workspace_settings.settings.api_tokens.never_expires")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{isSubmitting
|
||||
? t("workspace_settings.settings.api_tokens.generating")
|
||||
: t("workspace_settings.settings.api_tokens.generate_token")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { Copy } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IApiToken } from "@plane/types";
|
||||
// ui
|
||||
import { renderFormattedDate, renderFormattedTime, copyTextToClipboard } from "@plane/utils";
|
||||
// helpers
|
||||
// types
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// hooks
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
tokenDetails: IApiToken;
|
||||
};
|
||||
|
||||
export const GeneratedTokenDetails: React.FC<Props> = (props) => {
|
||||
const { handleClose, tokenDetails } = props;
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { t } = useTranslation();
|
||||
const copyApiToken = (token: string) => {
|
||||
copyTextToClipboard(token).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: `${t("success")}!`,
|
||||
message: t("workspace_settings.token_copied"),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full p-5">
|
||||
<div className="w-full space-y-3 text-wrap">
|
||||
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{t("workspace_settings.key_created")}</h3>
|
||||
<p className="text-sm text-custom-text-400">{t("workspace_settings.copy_key")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyApiToken(tokenDetails.token ?? "")}
|
||||
className="mt-4 flex truncate w-full items-center justify-between rounded-md border-[0.5px] border-custom-border-200 px-3 py-2 text-sm font-medium outline-none"
|
||||
>
|
||||
<span className="truncate pr-2">{tokenDetails.token}</span>
|
||||
<Tooltip tooltipContent="Copy secret key" isMobile={isMobile}>
|
||||
<Copy className="h-4 w-4 text-custom-text-400 flex-shrink-0" />
|
||||
</Tooltip>
|
||||
</button>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<p className="text-xs text-custom-text-400">
|
||||
{tokenDetails.expired_at
|
||||
? `Expires ${renderFormattedDate(tokenDetails.expired_at!)} at ${renderFormattedTime(tokenDetails.expired_at!)}`
|
||||
: "Never expires"}
|
||||
</p>
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user