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
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:
93
apps/web/core/components/api-token/delete-token-modal.tsx
Normal file
93
apps/web/core/components/api-token/delete-token-modal.tsx
Normal 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")} </>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
33
apps/web/core/components/api-token/empty-state.tsx
Normal file
33
apps/web/core/components/api-token/empty-state.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
64
apps/web/core/components/api-token/token-list-item.tsx
Normal file
64
apps/web/core/components/api-token/token-list-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user