[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:
Akshita Goyal 2025-06-18 16:08:11 +05:30 committed by GitHub
parent c7d17d00b7
commit d65f0e264e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 469 additions and 146 deletions

View file

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

View file

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

View file

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

View file

@ -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`;

View file

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

View file

@ -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(() => {