Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { mutate } from "swr";
// types
import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { APITokenService } from "@plane/services";
import type { IApiToken } from "@plane/types";
// ui
import { AlertModalCore } from "@plane/ui";
// fetch-keys
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
type Props = {
isOpen: boolean;
onClose: () => void;
tokenId: string;
};
const apiTokenService = new APITokenService();
export const DeleteApiTokenModal: FC<Props> = (props) => {
const { isOpen, onClose, tokenId } = props;
// states
const [deleteLoading, setDeleteLoading] = useState<boolean>(false);
// router params
const { t } = useTranslation();
const handleClose = () => {
onClose();
setDeleteLoading(false);
};
const handleDeletion = async () => {
setDeleteLoading(true);
await apiTokenService
.destroy(tokenId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_settings.settings.api_tokens.delete.success.title"),
message: t("workspace_settings.settings.api_tokens.delete.success.message"),
});
mutate<IApiToken[]>(
API_TOKENS_LIST,
(prevData) => (prevData ?? []).filter((token) => token.id !== tokenId),
false
);
captureSuccess({
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted,
payload: {
token: tokenId,
},
});
handleClose();
})
.catch((err) =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_settings.settings.api_tokens.delete.error.title"),
message: err?.message ?? t("workspace_settings.settings.api_tokens.delete.error.message"),
})
)
.catch((err) => {
captureError({
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted,
payload: {
token: tokenId,
},
error: err as Error,
});
})
.finally(() => setDeleteLoading(false));
};
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDeletion}
isSubmitting={deleteLoading}
isOpen={isOpen}
title={t("workspace_settings.settings.api_tokens.delete.title")}
content={<>{t("workspace_settings.settings.api_tokens.delete.description")} </>}
/>
);
};

View File

@@ -0,0 +1,33 @@
"use client";
import React from "react";
import Image from "next/image";
// ui
import { Button } from "@plane/propel/button";
// assets
import emptyApiTokens from "@/app/assets/empty-state/api-token.svg?url";
type Props = {
onClick: () => void;
};
export const ApiTokenEmptyState: React.FC<Props> = (props) => {
const { onClick } = props;
return (
<div
className={`mx-auto flex w-full items-center justify-center rounded-sm border border-custom-border-200 bg-custom-background-90 px-16 py-10 lg:w-3/4`}
>
<div className="flex w-full flex-col items-center text-center">
<Image src={emptyApiTokens} className="w-52 sm:w-60" alt="empty" />
<h6 className="mb-3 mt-6 text-xl font-semibold sm:mt-8">No API tokens</h6>
<p className="mb-7 text-custom-text-300 sm:mb-8">
Create API tokens for safe and easy data sharing with external apps, maintaining control and security.
</p>
<Button className="flex items-center gap-1.5" onClick={onClick}>
Add token
</Button>
</div>
</div>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { XCircle } from "lucide-react";
// plane imports
import { PROFILE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
import { Tooltip } from "@plane/propel/tooltip";
import type { IApiToken } from "@plane/types";
import { renderFormattedDate, calculateTimeAgo, renderFormattedTime } from "@plane/utils";
// components
import { DeleteApiTokenModal } from "@/components/api-token/delete-token-modal";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
token: IApiToken;
};
export const ApiTokenListItem: React.FC<Props> = (props) => {
const { token } = props;
// states
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
// hooks
const { isMobile } = usePlatformOS();
return (
<>
<DeleteApiTokenModal isOpen={deleteModalOpen} onClose={() => setDeleteModalOpen(false)} tokenId={token.id} />
<div className="group relative flex flex-col justify-center border-b border-custom-border-200 py-3">
<Tooltip tooltipContent="Delete token" isMobile={isMobile}>
<button
onClick={() => setDeleteModalOpen(true)}
className="absolute right-4 hidden place-items-center group-hover:grid"
data-ph-element={PROFILE_SETTINGS_TRACKER_ELEMENTS.LIST_ITEM_DELETE_ICON}
>
<XCircle className="h-4 w-4 text-red-500" />
</button>
</Tooltip>
<div className="flex w-4/5 items-center">
<h5 className="truncate text-sm font-medium">{token.label}</h5>
<span
className={`${
token.is_active ? "bg-green-500/10 text-green-500" : "bg-custom-background-80 text-custom-text-400"
} ml-2 flex h-4 max-h-fit items-center rounded-sm px-2 text-xs font-medium`}
>
{token.is_active ? "Active" : "Expired"}
</span>
</div>
<div className="mt-1 flex w-full flex-col justify-center">
{token.description.trim() !== "" && (
<p className="mb-1 max-w-[70%] break-words text-sm">{token.description}</p>
)}
<p className="mb-1 text-xs leading-6 text-custom-text-400">
{token.is_active
? token.expired_at
? `Expires ${renderFormattedDate(token.expired_at!)} at ${renderFormattedTime(token.expired_at!)}`
: "Never expires"
: `Expired ${calculateTimeAgo(token.expired_at)}`}
</p>
</div>
</div>
</>
);
};