mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
[WEB-4327] Chore PAT permissions (#7224)
* chore: improved pat permissions * fix: err message * fix: removed permission from backend * [WEB-4330] refactor: update API token endpoints to use user context instead of workspace slug - Changed URL patterns for API token endpoints to use "users/api-tokens/" instead of "workspaces/<str:slug>/api-tokens/". - Refactored ApiTokenEndpoint methods to remove workspace slug parameter and adjust database queries accordingly. - Added new test cases for API token creation, retrieval, deletion, and updates, including support for bot users and minimal data submissions. * fix: removed workspace slug from api-tokens * fix: refactor * chore: url.py code rabbit suggestion * fix: APITokenService moved to package --------- Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com> Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
@@ -2,24 +2,21 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// component
|
||||
import { APITokenService } from "@plane/services";
|
||||
import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { DetailedEmptyState } from "@/components/empty-state";
|
||||
import { SettingsHeading } from "@/components/settings";
|
||||
import { APITokenSettingsLoader } from "@/components/ui";
|
||||
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
|
||||
// store hooks
|
||||
import { useUserPermissions, useWorkspace } from "@/hooks/store";
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
// services
|
||||
import { APITokenService } from "@/services/api_token.service";
|
||||
|
||||
const apiTokenService = new APITokenService();
|
||||
|
||||
@@ -27,30 +24,19 @@ const ApiTokensPage = observer(() => {
|
||||
// states
|
||||
const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false);
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/api-tokens" });
|
||||
|
||||
const { data: tokens } = useSWR(
|
||||
workspaceSlug && canPerformWorkspaceAdminActions ? API_TOKENS_LIST(workspaceSlug.toString()) : null,
|
||||
() =>
|
||||
workspaceSlug && canPerformWorkspaceAdminActions ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null
|
||||
);
|
||||
const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list());
|
||||
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}`
|
||||
: undefined;
|
||||
|
||||
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
|
||||
return <NotAuthorizedView section="settings" className="h-auto" />;
|
||||
}
|
||||
|
||||
if (!tokens) {
|
||||
return <APITokenSettingsLoader />;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,12 @@ import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks, Lock } from "lucide-react";
|
||||
// plane imports
|
||||
import {
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
GROUPED_PROFILE_SETTINGS,
|
||||
PROFILE_SETTINGS_CATEGORIES,
|
||||
PROFILE_SETTINGS_CATEGORY,
|
||||
} from "@plane/constants";
|
||||
import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { SettingsSidebar } from "@/components/settings";
|
||||
// hooks
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
const ICONS = {
|
||||
profile: CircleUser,
|
||||
@@ -44,14 +38,10 @@ export const ProfileSidebar = observer((props: TProfileSidebarProps) => {
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
return (
|
||||
<SettingsSidebar
|
||||
isMobile={isMobile}
|
||||
categories={PROFILE_SETTINGS_CATEGORIES.filter(
|
||||
(category) => isAdmin || category !== PROFILE_SETTINGS_CATEGORY.DEVELOPER
|
||||
)}
|
||||
categories={PROFILE_SETTINGS_CATEGORIES}
|
||||
groupedSettings={GROUPED_PROFILE_SETTINGS}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
isActive={(data: { href: string }) => pathname === `/${workspaceSlug}${data.href}/`}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState, FC } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { mutate } from "swr";
|
||||
// types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { APITokenService } from "@plane/services";
|
||||
import { IApiToken } from "@plane/types";
|
||||
// ui
|
||||
import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// fetch-keys
|
||||
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
|
||||
// services
|
||||
import { APITokenService } from "@/services/api_token.service";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@@ -26,7 +24,6 @@ export const DeleteApiTokenModal: FC<Props> = (props) => {
|
||||
// states
|
||||
const [deleteLoading, setDeleteLoading] = useState<boolean>(false);
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -35,12 +32,10 @@ export const DeleteApiTokenModal: FC<Props> = (props) => {
|
||||
};
|
||||
|
||||
const handleDeletion = async () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
setDeleteLoading(true);
|
||||
|
||||
await apiTokenService
|
||||
.deleteApiToken(workspaceSlug.toString(), tokenId)
|
||||
.destroy(tokenId)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
@@ -49,7 +44,7 @@ export const DeleteApiTokenModal: FC<Props> = (props) => {
|
||||
});
|
||||
|
||||
mutate<IApiToken[]>(
|
||||
API_TOKENS_LIST(workspaceSlug.toString()),
|
||||
API_TOKENS_LIST,
|
||||
(prevData) => (prevData ?? []).filter((token) => token.id !== tokenId),
|
||||
false
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { mutate } from "swr";
|
||||
// types
|
||||
import { APITokenService } from "@plane/services";
|
||||
import { IApiToken } from "@plane/types";
|
||||
// ui
|
||||
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
@@ -14,7 +14,6 @@ import { CreateApiTokenForm, GeneratedTokenDetails } from "@/components/api-toke
|
||||
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
|
||||
// helpers
|
||||
// services
|
||||
import { APITokenService } from "@/services/api_token.service";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@@ -29,8 +28,6 @@ export const CreateApiTokenModal: React.FC<Props> = (props) => {
|
||||
// states
|
||||
const [neverExpires, setNeverExpires] = useState<boolean>(false);
|
||||
const [generatedToken, setGeneratedToken] = useState<IApiToken | null | undefined>(null);
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
@@ -53,17 +50,15 @@ export const CreateApiTokenModal: React.FC<Props> = (props) => {
|
||||
};
|
||||
|
||||
const handleCreateToken = async (data: Partial<IApiToken>) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
// make the request to generate the token
|
||||
await apiTokenService
|
||||
.createApiToken(workspaceSlug.toString(), data)
|
||||
.create(data)
|
||||
.then((res) => {
|
||||
setGeneratedToken(res);
|
||||
downloadSecretKey(res);
|
||||
|
||||
mutate<IApiToken[]>(
|
||||
API_TOKENS_LIST(workspaceSlug.toString()),
|
||||
API_TOKENS_LIST,
|
||||
(prevData) => {
|
||||
if (!prevData) return;
|
||||
|
||||
@@ -76,7 +71,7 @@ export const CreateApiTokenModal: React.FC<Props> = (props) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err.message,
|
||||
message: err.message || err.detail,
|
||||
});
|
||||
|
||||
throw err;
|
||||
|
||||
@@ -20,14 +20,14 @@ export const SettingsHeading = ({
|
||||
customButton,
|
||||
showButton = true,
|
||||
}: Props) => (
|
||||
<div className="flex items-center justify-between border-b border-custom-border-100 pb-3.5">
|
||||
<div className="flex flex-col md:flex-row gap-2 items-start md:items-center justify-between border-b border-custom-border-100 pb-3.5">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{typeof title === "string" ? <h3 className="text-xl font-medium">{title}</h3> : title}
|
||||
{description && <div className="text-sm text-custom-text-300">{description}</div>}
|
||||
</div>
|
||||
{showButton && customButton}
|
||||
{button && showButton && (
|
||||
<Button variant="primary" onClick={button.onClick} size="sm">
|
||||
<Button variant="primary" onClick={button.onClick} size="sm" className="w-fit">
|
||||
{button.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -114,4 +114,4 @@ export const USER_PROFILE_PROJECT_SEGREGATION = (workspaceSlug: string, userId:
|
||||
`USER_PROFILE_PROJECT_SEGREGATION_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
|
||||
|
||||
// api-tokens
|
||||
export const API_TOKENS_LIST = (workspaceSlug: string) => `API_TOKENS_LIST_${workspaceSlug.toUpperCase()}`;
|
||||
export const API_TOKENS_LIST = `API_TOKENS_LIST`;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { IApiToken } from "@plane/types";
|
||||
import { APIService } from "./api.service";
|
||||
|
||||
export class APITokenService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getApiTokens(workspaceSlug: string): Promise<IApiToken[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async retrieveApiToken(workspaceSlug: string, tokenId: string): Promise<IApiToken> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async createApiToken(workspaceSlug: string, data: Partial<IApiToken>): Promise<IApiToken> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/api-tokens/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteApiToken(workspaceSlug: string, tokenId: string): Promise<IApiToken> {
|
||||
return this.delete(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { action, observable, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import { APITokenService } from "@plane/services";
|
||||
import { IApiToken } from "@plane/types";
|
||||
// services
|
||||
import { APITokenService } from "@/services/api_token.service";
|
||||
// store
|
||||
import { CoreRootStore } from "../root.store";
|
||||
|
||||
@@ -13,11 +13,11 @@ export interface IApiTokenStore {
|
||||
// computed actions
|
||||
getApiTokenById: (apiTokenId: string) => IApiToken | null;
|
||||
// fetch actions
|
||||
fetchApiTokens: (workspaceSlug: string) => Promise<IApiToken[]>;
|
||||
fetchApiTokenDetails: (workspaceSlug: string, tokenId: string) => Promise<IApiToken>;
|
||||
fetchApiTokens: () => Promise<IApiToken[]>;
|
||||
fetchApiTokenDetails: (tokenId: string) => Promise<IApiToken>;
|
||||
// crud actions
|
||||
createApiToken: (workspaceSlug: string, data: Partial<IApiToken>) => Promise<IApiToken>;
|
||||
deleteApiToken: (workspaceSlug: string, tokenId: string) => Promise<void>;
|
||||
createApiToken: (data: Partial<IApiToken>) => Promise<IApiToken>;
|
||||
deleteApiToken: (tokenId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ApiTokenStore implements IApiTokenStore {
|
||||
@@ -55,11 +55,10 @@ export class ApiTokenStore implements IApiTokenStore {
|
||||
});
|
||||
|
||||
/**
|
||||
* fetch all the API tokens for a workspace
|
||||
* @param workspaceSlug
|
||||
* fetch all the API tokens
|
||||
*/
|
||||
fetchApiTokens = async (workspaceSlug: string) =>
|
||||
await this.apiTokenService.getApiTokens(workspaceSlug).then((response) => {
|
||||
fetchApiTokens = async () =>
|
||||
await this.apiTokenService.list().then((response) => {
|
||||
const apiTokensObject: { [apiTokenId: string]: IApiToken } = response.reduce((accumulator, currentWebhook) => {
|
||||
if (currentWebhook && currentWebhook.id) {
|
||||
return { ...accumulator, [currentWebhook.id]: currentWebhook };
|
||||
@@ -74,11 +73,10 @@ export class ApiTokenStore implements IApiTokenStore {
|
||||
|
||||
/**
|
||||
* fetch API token details using token id
|
||||
* @param workspaceSlug
|
||||
* @param tokenId
|
||||
*/
|
||||
fetchApiTokenDetails = async (workspaceSlug: string, tokenId: string) =>
|
||||
await this.apiTokenService.retrieveApiToken(workspaceSlug, tokenId).then((response) => {
|
||||
fetchApiTokenDetails = async (tokenId: string) =>
|
||||
await this.apiTokenService.retrieve(tokenId).then((response) => {
|
||||
runInAction(() => {
|
||||
this.apiTokens = { ...this.apiTokens, [response.id]: response };
|
||||
});
|
||||
@@ -87,11 +85,10 @@ export class ApiTokenStore implements IApiTokenStore {
|
||||
|
||||
/**
|
||||
* create API token using data
|
||||
* @param workspaceSlug
|
||||
* @param data
|
||||
*/
|
||||
createApiToken = async (workspaceSlug: string, data: Partial<IApiToken>) =>
|
||||
await this.apiTokenService.createApiToken(workspaceSlug, data).then((response) => {
|
||||
createApiToken = async (data: Partial<IApiToken>) =>
|
||||
await this.apiTokenService.create(data).then((response) => {
|
||||
runInAction(() => {
|
||||
this.apiTokens = { ...this.apiTokens, [response.id]: response };
|
||||
});
|
||||
@@ -100,11 +97,10 @@ export class ApiTokenStore implements IApiTokenStore {
|
||||
|
||||
/**
|
||||
* delete API token using token id
|
||||
* @param workspaceSlug
|
||||
* @param tokenId
|
||||
*/
|
||||
deleteApiToken = async (workspaceSlug: string, tokenId: string) =>
|
||||
await this.apiTokenService.deleteApiToken(workspaceSlug, tokenId).then(() => {
|
||||
deleteApiToken = async (tokenId: string) =>
|
||||
await this.apiTokenService.destroy(tokenId).then(() => {
|
||||
const updatedApiTokens = { ...this.apiTokens };
|
||||
delete updatedApiTokens[tokenId];
|
||||
runInAction(() => {
|
||||
|
||||
Reference in New Issue
Block a user