diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index c0c1c484808b..6eed5e854cc7 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -6,6 +6,7 @@ import * as newtype from '../utilities/data/newtype.js' import * as permissions from '../utilities/permissions.js' import { getFileDetailsPath } from './Backend/remoteBackendPaths.js' import { + ApiKeyId, DatalinkId, DirectoryId, EnsoPath, @@ -590,6 +591,11 @@ export interface RemoteBackendError { readonly param: string } +/** HTTP response body for the "list api keys" endpoint. */ +export interface ListApiKeysResponse { + readonly credentials: readonly ApiKey[] +} + /** HTTP response body for the "list users" endpoint. */ export interface ListUsersResponseBody { readonly users: readonly User[] @@ -793,6 +799,35 @@ export interface LChColor { readonly alpha?: number | undefined } +/** Type used when creating api key credential. */ +export interface CreateApiKeyRequestBody { + readonly name: string + readonly description: string + readonly expiresIn: ApiKeyExpiresIn +} + +/** Api key credential. */ +export interface ApiKey { + readonly id: ApiKeyId + // Field populated only once after creation. + readonly secretId: string | null + readonly name: string + readonly description: string + readonly createdAt: dateTime.Rfc3339DateTime + readonly lastUsedAt: dateTime.Rfc3339DateTime | null + readonly expiresAt: dateTime.Rfc3339DateTime | null +} + +/** Possible types of lifetime span for api key credentials. */ +export enum ApiKeyExpiresIn { + Week = 'Week', + Month = 'Month', + Year = 'Year', + Indefinetly = 'Indefinetly', +} + +export const API_KEY_EXPIRES_IN_VALUES: readonly ApiKeyExpiresIn[] = Object.values(ApiKeyExpiresIn) + /** A pre-selected list of colors to be used in color pickers. */ export const COLORS = [ // Red @@ -1167,9 +1202,7 @@ export interface UpdateProjectRequestBody { readonly projectName: string | null } -/** - * Extra parameters required when opening the project in hybrid mode. - */ +/** Extra parameters required when opening the project in hybrid mode. */ export interface OpenHybridProjectParameters { /** Cloud project directory path. */ readonly cloudProjectDirectoryPath: EnsoPath @@ -1956,6 +1989,13 @@ export default abstract class Backend { /** Fetches pricing page configuration. */ abstract getPaymentsConfig(): Promise + /** List all API keys for the current user. */ + abstract listApiKeys(): Promise + /** Create a new API key for the current user. */ + abstract createApiKey(body: CreateApiKeyRequestBody): Promise + /** Delete a API key for the current user. */ + abstract deleteApiKey(apiKeyId: ApiKeyId): Promise + /** Throw a {@link backend.NotAuthorizedError} if the response is a 401 Not Authorized status code. */ private async checkForAuthenticationError( makeRequest: () => Promise>, diff --git a/app/common/src/services/Backend/remoteBackendPaths.ts b/app/common/src/services/Backend/remoteBackendPaths.ts index f4107f19001e..26c73e438dfc 100644 --- a/app/common/src/services/Backend/remoteBackendPaths.ts +++ b/app/common/src/services/Backend/remoteBackendPaths.ts @@ -1,5 +1,6 @@ /** @file Paths used by the `RemoteBackend`. */ import { + ApiKeyId, DirectoryId, HttpsUrl, type AssetId, @@ -88,10 +89,18 @@ export const GET_LOG_EVENTS_PATH = 'log_events' export const POST_LOG_EVENT_PATH = 'logs' /** Relative HTTP path to the "get payments config" endpoint of the Cloud backend API. */ export const PAYMENTS_CONFIG_PATH = 'payments/config' -/** Resolve an enso URL path. */ +/** Relative HTTP path to the "resolve an enso URL path" endpoint of the Cloud backend API. */ export const RESOLVE_ENSO_PATH = 'path/resolve' /** Relative HTTP path to the "get customer portal session" endpoint of the Cloud backend API. */ export const CUSTOMER_PORTAL_SESSION_CREATE_PATH = 'payments/customer-portal-sessions/create' +/** Relative HTTP path to the "API keys" endpoint of the Cloud backend API. */ +export const LIST_API_KEYS_PATH = 'credentials' +/** Relative HTTP path to the "create API key" endpoint of the Cloud backend API. */ +export const CREATE_API_KEY_PATH = 'credentials' +/** Relative HTTP path to the "delete API key" endpoint of the Cloud backend API. */ +export function deleteApiKeyPath(apiKeyId: ApiKeyId) { + return `credentials/${apiKeyId}` +} /** Relative HTTP path to the "cancel subscription" endpoint of the Cloud backend API. */ export function cancelSubscriptionPath(subscriptionId: SubscriptionId) { diff --git a/app/common/src/services/Backend/types.ts b/app/common/src/services/Backend/types.ts index 0e034a82a20f..0f2c85fa3ea5 100644 --- a/app/common/src/services/Backend/types.ts +++ b/app/common/src/services/Backend/types.ts @@ -110,6 +110,10 @@ export const ZipAssetsJobId = newtypeConstructor() export type UnzipAssetsJobId = Newtype export const UnzipAssetsJobId = newtypeConstructor() +/** Unique identifier for an API key. */ +export type ApiKeyId = Newtype +export const ApiKeyId = newtypeConstructor() + /** The name of an asset label. */ export type LabelName = Newtype export const LabelName = newtypeConstructor() diff --git a/app/common/src/text.ts b/app/common/src/text.ts index 103740e1f8c8..0f04f125a1ae 100644 --- a/app/common/src/text.ts +++ b/app/common/src/text.ts @@ -186,6 +186,9 @@ interface PlaceholderOverrides { readonly welcomeToTeam: [organizationName: string] readonly invitationText: [organizationName: string] + + readonly youCanCreateXMoreApiKeys: [apiKeysLeft: number] + readonly deleteApiKeyConfirmation: [tokenName: string] } // This is intentionally unused. This line throws an error if `PlaceholderOverrides` ever becomes diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index 62cc576425c4..02fea2709c00 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -57,8 +57,9 @@ "changeUserGroupsError": "Could not set user groups", "deleteUserGroupError": "Could not delete user group '$0'", "deleteUserError": "Could not delete user '$0'", - "deleteUserConfirmation": "permanently delete user: '$0 ($1)'", + "deleteUserConfirmation": "permanently delete the user '$0 ($1)'", "deleteUserAlert": "We will transfer all belonging assets to the organization admin account.", + "deleteApiKeyConfirmation": "delete the API key '$0'", "anotherProjectIsBeingOpenedError": "Another project is currently being opened", "syncingProjectFiles": "Synchronizing Project Files", "localBackendNotDetectedError": "Could not detect the local backend", @@ -183,6 +184,7 @@ "logEventBackendError": "Could not log an event '$0'", "getDefaultVersionBackendError": "No default $0 version found", "duplicateUserGroupError": "This user group already exists", + "duplicateApiKeyError": "An API key with this name already exists.", "getCustomerPortalUrlBackendError": "Could not get customer portal URL", "exportArchiveBackendError": "Could not export archive", "duplicateLabelError": "This label already exists.", @@ -596,6 +598,7 @@ "organizationInviteErrorMessage": "Something went wrong. Please contact the administrators at", "youHaveNoUserGroupsAdmin": "This organization has no user groups. You can create one using the button above.", "youHaveNoUserGroupsNonAdmin": "This organization has no user groups. You can create one using the button above.", + "youHaveNoApiKeys": "You have no API keys. You can create one using the button above.", "xIsUsingTheProject": "'$0' is currently using the project", "uploadLargeFileStatus": "Uploading file... ($0/$1MB)", "uploadLargeFileSuccess": "Finished uploading file.", @@ -925,6 +928,12 @@ "userGroupsPaywallMessage": "You have reached the limit of user groups for your plan. Upgrade to create more user groups.", "userGroupsLimitMessage": "You can create up to $0 user group(s)", "userGroupNamePlaceholder": "Enter the name of the user group", + "apiKeys": "API Keys", + "newApiKey": "New API Key", + "expiresIn": "Expiration", + "createdAt": "Created at", + "lastUsedAt": "Last used at", + "never": "Never", "assetSearchFieldLabel": "Search through items", "startModalLabel": "Start modal", "userMenuLabel": "User Settings", @@ -1039,6 +1048,9 @@ "securitySettingsTabSection": "Security", "activityLogSettingsTab": "Activity log", "activityLogSettingsSection": "Activity Log", + "apiKeysSettingsTab": "API Keys", + "apiKeysSettingsSection": "API Keys", + "apiKeysSettingsCustomEntryAliases": "api keys\napi key", "free": "Community", "freePlan": "Community Plan", "solo": "Solo", @@ -1250,14 +1262,22 @@ "downgradedTitle": "Cloud Assets Removal", "downgradedExplanation": "You have recently downgraded your plan to Community, which means you can no longer store assets in cloud.", "downgradedWarning": "We will permanently remove all of your cloud assets in $0 days and $1 hours.", - "cancelSubscriptionBackendError": "We couldn't cancel your subscription. Please contant the administrator.", + "cancelSubscriptionBackendError": "Could not cancel your subscription. Please contact an administrator.", "downgrade": "Downgrade", - "getPaymentsConfigBackendError": "Couldn't get payment pricing page configuration. Please try again later.", + "getPaymentsConfigBackendError": "Could not get payment pricing page configuration.", + "listApiKeysBackendError": "Could not list API keys.", + "createApiKeyBackendError": "Could not create API key.", + "deleteApiKeyBackendError": "Could not delete API key.", + "keyId": "Access key", + "secretId": "Secret access key", + "accessKeyAlert": "If you lose or forget your secret access key, you cannot retrieve it. Instead, create a new access key and make the old key inactive.", + "youCanCreateXMoreApiKeys": "You can create $0 more API key(s).", + "youHaveTheMaximumNumberOfApiKeys": "You have the maximum number of API keys. Delete existing keys to create new ones.", "stripeRedirectAlert": "You are being redirected to Stripe for finalizing your payment process.", "stripeRedirectInfo": "After submitting your order you will be redirect to Stripe for finalizing your payment process.", "goToStripe": "Go to Stripe", "welcomeToTeam": "Welcome to the \"$0\" team!", - "invitationError": "Something went wrong. Please contact the administrator.", + "invitationError": "Something went wrong when inviting a user. Please contact an administrator.", "pendingInvitationInfo": "You have pending team invitation.", "invitationText": "\"$0\" invites you to join", "invitationAlert": "All of your assets will be transfered with you. This might take a while.", diff --git a/app/gui/src/dashboard/layouts/Settings/ApiKeysSettingsSection.tsx b/app/gui/src/dashboard/layouts/Settings/ApiKeysSettingsSection.tsx new file mode 100644 index 000000000000..ec534eb912a0 --- /dev/null +++ b/app/gui/src/dashboard/layouts/Settings/ApiKeysSettingsSection.tsx @@ -0,0 +1,231 @@ +/** @file Settings section for viewing and managing API keys. */ +import { Alert } from '#/components/Alert' +import { Cell, Column, Row, Table, TableBody, TableHeader } from '#/components/aria' +import { Button, CopyButton } from '#/components/Button' +import { Dialog, Popover, type DialogProps } from '#/components/Dialog' +import { Form } from '#/components/Form' +import { Input } from '#/components/Inputs/Input' +import { Selector } from '#/components/Inputs/Selector' +import { Scroller } from '#/components/Scroller' +import { Text } from '#/components/Text' +import { backendMutationOptions, backendQueryOptions } from '#/hooks/backendHooks' +import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal' +import { setModal } from '#/providers/ModalProvider' +import { API_KEY_EXPIRES_IN_VALUES, ApiKeyExpiresIn, type ApiKey } from '#/services/Backend' +import { useMutationCallback } from '#/utilities/tanstackQuery' +import { useBackends, useText } from '$/providers/react' +import { useFeatureFlag } from '$/providers/react/featureFlags' +import { useSuspenseQuery } from '@tanstack/react-query' +import { toReadableIsoString } from 'enso-common/src/utilities/data/dateTime' + +const COLUMN_STYLES = + 'w-full border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0' + +/** Settings tab for viewing and managing API keys. */ +export function ApiKeySettingsSection() { + const { remoteBackend: backend } = useBackends() + const { getText } = useText() + const { data: apiKeys } = useSuspenseQuery(backendQueryOptions(backend, 'listApiKeys', [])) + const apiKeyLimit = useFeatureFlag('apiKeyLimit') + const apiKeysLeft = apiKeyLimit - apiKeys.length + const canCreateMoreApiKeys = apiKeysLeft > 0 + + return ( +
+ + + + + + + + + {apiKeysLeft <= 0 ? + getText('youHaveTheMaximumNumberOfApiKeys') + : getText('youCanCreateXMoreApiKeys', apiKeysLeft)} + + + + + + + {getText('name')} + + + {getText('description')} + + + {getText('createdAt')} + + + {getText('lastUsedAt')} + + + {getText('actions')} + + + + {apiKeys.length === 0 ? + + { + if (!el) { + return + } + // This is SAFE; `react-aria-components` simply is missing types. + // This will be unnecessary when the `react-aria-components` dependency is updated as it adds support for `colSpan`. + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-magic-numbers + ;(el as HTMLTableCellElement).colSpan = 999 + }} + className="px-2.5 placeholder" + > + {getText('youHaveNoApiKeys')} + + + : (apiKey) => } + +
+
+
+ ) +} + +/** Props for an {@link ApiKeyRow}. */ +interface ApiKeyRowProps { + /** The API key to display in the row. */ + readonly apiKey: ApiKey +} + +/** A row in the {@link ApiKeySettingsSection} table. */ +function ApiKeyRow(props: ApiKeyRowProps) { + const { apiKey } = props + const { remoteBackend: backend } = useBackends() + const { getText } = useText() + const deleteApiKey = useMutationCallback(backendMutationOptions(backend, 'deleteApiKey')) + + return ( + + + {apiKey.name} + + + {apiKey.description} + + + {toReadableIsoString(new Date(apiKey.createdAt))} + + + {apiKey.lastUsedAt ? toReadableIsoString(new Date(apiKey.lastUsedAt)) : getText('never')} + + + + + + deleteApiKey([apiKey.id])} + /> + + + + + ) +} + +/** Props for a {@link ApiKeyDialog}. */ +interface ApiKeyDialogProps extends DialogProps { + readonly apiKey: ApiKey +} + +/** Dialog propmpted after successful api key submit. Shows the api key secret to the user. */ +function ApiKeyDialog(props: ApiKeyDialogProps) { + const { apiKey, type = 'modal', ...dialogProps } = props + const { getText } = useText() + + return ( + +
+
+ + {getText('accessKeyAlert')} + + + + + + + + + + + + + +
{getText('keyId')}{getText('secretId')}
+ + {apiKey.id} + + + + {apiKey.secretId} + +
+
+
+
+ ) +} + +/** A form to create an API key. */ +function NewApiKeyForm() { + const { remoteBackend: backend } = useBackends() + const { getText } = useText() + const { data: apiKeys } = useSuspenseQuery(backendQueryOptions(backend, 'listApiKeys', [])) + const apiKeyNames = new Set(apiKeys.map((apiKey) => apiKey.name)) + const createApiKey = useMutationCallback(backendMutationOptions(backend, 'createApiKey')) + + return ( +
+ z.object({ + name: z + .string() + .min(1) + .refine((name) => !apiKeyNames.has(name), getText('duplicateApiKeyError')), + description: z.string(), + expiresIn: z.nativeEnum(ApiKeyExpiresIn), + }) + } + method="dialog" + onSubmit={(values) => createApiKey([values])} + onSubmitSuccess={(apiKey) => + setModal() + } + > + {getText('newApiKey')} + + + + + + {getText('cancel')} + + + + ) +} diff --git a/app/gui/src/dashboard/layouts/Settings/TabType.ts b/app/gui/src/dashboard/layouts/Settings/TabType.ts index 126ed3218ead..fc9d6ee65eb9 100644 --- a/app/gui/src/dashboard/layouts/Settings/TabType.ts +++ b/app/gui/src/dashboard/layouts/Settings/TabType.ts @@ -17,7 +17,7 @@ enum SettingsTabType { activityLog = 'activity-log', // compliance = 'compliance', // usageStatistics = 'usage-statistics', - // personalAccessToken = 'personal-access-token', + apiKeys = 'api-keys', } export default SettingsTabType diff --git a/app/gui/src/dashboard/layouts/Settings/UserGroupsSettingsSection.tsx b/app/gui/src/dashboard/layouts/Settings/UserGroupsSettingsSection.tsx index ca7b698c49ff..48c0de573aec 100644 --- a/app/gui/src/dashboard/layouts/Settings/UserGroupsSettingsSection.tsx +++ b/app/gui/src/dashboard/layouts/Settings/UserGroupsSettingsSection.tsx @@ -4,6 +4,7 @@ import { Button } from '#/components/Button' import { Dialog, Popover } from '#/components/Dialog' import { Form } from '#/components/Form' import { ComboBox } from '#/components/Inputs/ComboBox' +import { Input } from '#/components/Inputs/Input' import { Menu } from '#/components/Menu' import { PaywallDialogButton } from '#/components/Paywall' import { ProfilePicture } from '#/components/ProfilePicture' @@ -14,9 +15,9 @@ import { VisualTooltip } from '#/components/VisualTooltip' import { backendMutationOptions, backendQueryOptions } from '#/hooks/backendHooks' import { usePaywall } from '#/hooks/billing' import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal' -import { NewUserGroupForm } from '#/modals/NewUserGroupForm' import { setModal, unsetModal } from '#/providers/ModalProvider' import type { EmailAddress, User, UserGroupInfo } from '#/services/Backend' +import { normalizeName } from '#/utilities/string' import { tv } from '#/utilities/tailwindVariants' import { useMutationCallback } from '#/utilities/tanstackQuery' import { useBackends, useFullUserSession, useText } from '$/providers/react' @@ -140,7 +141,18 @@ function UserGroupsSettingsRootSection(props: UserGroupsSettingsRootSectionProps {userGroups.length === 0 ? - + { + if (!el) { + return + } + // This is SAFE; `react-aria-components` simply is missing types. + // This will be unnecessary when the `react-aria-components` dependency is updated as it adds support for `colSpan`. + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-magic-numbers + ;(el as HTMLTableCellElement).colSpan = 999 + }} + className="px-2.5 placeholder" + > {isAdmin ? getText('youHaveNoUserGroupsAdmin') : getText('youHaveNoUserGroupsNonAdmin')} @@ -480,3 +492,38 @@ function UserGroupAddUserForm(props: UserGroupAddUserFormProps) { ) } + +/** A form to create a user group. */ +function NewUserGroupForm() { + const { remoteBackend: backend } = useBackends() + const { getText } = useText() + const { data: userGroups } = useSuspenseQuery(backendQueryOptions(backend, 'listUserGroups', [])) + const userGroupNames = new Set(userGroups.map((group) => normalizeName(group.groupName))) + const createUserGroup = useMutationCallback(backendMutationOptions(backend, 'createUserGroup')) + + return ( +
+ z.object({ + name: z + .string() + .min(1) + .refine( + (name) => !userGroupNames.has(normalizeName(name)), + getText('duplicateUserGroupError'), + ), + }) + } + method="dialog" + onSubmit={({ name }) => createUserGroup([{ name }])} + > + {getText('newUserGroup')} + + + + {getText('cancel')} + + + + ) +} diff --git a/app/gui/src/dashboard/layouts/Settings/data.tsx b/app/gui/src/dashboard/layouts/Settings/data.tsx index 88cb105df805..f108c431cb34 100644 --- a/app/gui/src/dashboard/layouts/Settings/data.tsx +++ b/app/gui/src/dashboard/layouts/Settings/data.tsx @@ -8,6 +8,7 @@ import { BINDINGS } from '#/configurations/inputBindings' import type { PaywallFeatureName } from '#/hooks/billing' import type { ToastAndLogCallback } from '#/hooks/toastAndLogHooks' import { setDownloadDirectory, setLocalRootDirectory } from '#/layouts/Drive/persistentState' +import { ApiKeySettingsSection } from '#/layouts/Settings/ApiKeysSettingsSection' import { passwordWithPatternSchema } from '#/pages/authentication/schemas' import type Backend from '#/services/Backend' import { @@ -552,6 +553,24 @@ export const SETTINGS_TAB_DATA: Readonly , + }, + ], + }, + ], + }, } export const SETTINGS_DATA: SettingsData = [ @@ -577,7 +596,10 @@ export const SETTINGS_DATA: SettingsData = [ }, { nameId: 'securitySettingsTabSection', - tabs: [SETTINGS_TAB_DATA[SettingsTabType.activityLog]], + tabs: [ + SETTINGS_TAB_DATA[SettingsTabType.activityLog], + SETTINGS_TAB_DATA[SettingsTabType.apiKeys], + ], }, ] diff --git a/app/gui/src/dashboard/modals/NewUserGroupForm.tsx b/app/gui/src/dashboard/modals/NewUserGroupForm.tsx deleted file mode 100644 index 87a0bd73f0be..000000000000 --- a/app/gui/src/dashboard/modals/NewUserGroupForm.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** @file A form to create a user group. */ -import { Button } from '#/components/Button' -import { Dialog } from '#/components/Dialog' -import { Form } from '#/components/Form' -import { Input } from '#/components/Inputs/Input' -import { Text } from '#/components/Text' -import { backendMutationOptions, backendQueryOptions } from '#/hooks/backendHooks' -import { normalizeName } from '#/utilities/string' -import { useMutationCallback } from '#/utilities/tanstackQuery' -import { useBackends, useText } from '$/providers/react' -import { useSuspenseQuery } from '@tanstack/react-query' - -/** A form to create a user group. */ -export function NewUserGroupForm() { - const { remoteBackend: backend } = useBackends() - const { getText } = useText() - const { data: userGroups } = useSuspenseQuery(backendQueryOptions(backend, 'listUserGroups', [])) - const userGroupNames = new Set(userGroups.map((group) => normalizeName(group.groupName))) - const createUserGroup = useMutationCallback(backendMutationOptions(backend, 'createUserGroup')) - - return ( -
- z.object({ - name: z - .string() - .min(1) - .refine( - (name) => !userGroupNames.has(normalizeName(name)), - getText('duplicateUserGroupError'), - ), - }) - } - method="dialog" - onSubmit={({ name }) => createUserGroup([{ name }])} - > - {getText('newUserGroup')} - - - - {getText('cancel')} - - - - ) -} diff --git a/app/gui/src/dashboard/services/LocalBackend.ts b/app/gui/src/dashboard/services/LocalBackend.ts index ccea542f5a37..ac95fdb2ff7a 100644 --- a/app/gui/src/dashboard/services/LocalBackend.ts +++ b/app/gui/src/dashboard/services/LocalBackend.ts @@ -1024,6 +1024,21 @@ export default class LocalBackend extends Backend { return this.invalidOperation() } + /** Invalid operation. */ + override listApiKeys() { + return this.invalidOperation() + } + + /** Invalid operation. */ + override createApiKey() { + return this.invalidOperation() + } + + /** Invalid operation. */ + override deleteApiKey() { + return this.invalidOperation() + } + /** Find asset details using directory listing. */ private async findAsset( directory: projectManager.Path, diff --git a/app/gui/src/dashboard/services/RemoteBackend.ts b/app/gui/src/dashboard/services/RemoteBackend.ts index f2f552a70250..331a9fe36e9c 100644 --- a/app/gui/src/dashboard/services/RemoteBackend.ts +++ b/app/gui/src/dashboard/services/RemoteBackend.ts @@ -818,6 +818,7 @@ export default class RemoteBackend extends Backend { title: string, ): Promise { const path = remoteBackendPaths.openProjectPath(projectId) + // `cognitoCredentials` is a legacy field, should be removed when no longer needed by the runtime. if (body.cognitoCredentials == null) { return this.throw(null, 'openProjectMissingCredentialsBackendError', title) } else { @@ -834,7 +835,6 @@ export default class RemoteBackend extends Backend { cognitoCredentials: exactCredentials, } const response = await this.post(path, filteredBody) - if (!response.ok) { return this.throw(response, 'openProjectBackendError', title) } else { @@ -1185,9 +1185,8 @@ export default class RemoteBackend extends Backend { * Fetches a configuration for a payment pricing page. * @throws An error if a non-successful status code (not 200-299) was received. */ - async getPaymentsConfig(): Promise { + override async getPaymentsConfig(): Promise { const response = await this.get(remoteBackendPaths.PAYMENTS_CONFIG_PATH) - if (!response.ok) { return await this.throw(response, 'getPaymentsConfigBackendError') } else { @@ -1195,6 +1194,48 @@ export default class RemoteBackend extends Backend { } } + /** + * List all personal access tokens for the current user. + * @throws An error if a non-successful status code (not 200-299) was received. + */ + override async listApiKeys(): Promise { + const response = await this.get( + remoteBackendPaths.LIST_API_KEYS_PATH, + ) + if (!response.ok) { + return await this.throw(response, 'listApiKeysBackendError') + } else { + return (await response.json()).credentials + } + } + + /** + * Create a new personal access token for the current user. + * @throws An error if a non-successful status code (not 200-299) was received. + */ + override async createApiKey(body: backend.CreateApiKeyRequestBody): Promise { + const response = await this.post(remoteBackendPaths.LIST_API_KEYS_PATH, body) + if (!response.ok) { + return await this.throw(response, 'createApiKeyBackendError') + } else { + return await response.json() + } + } + + /** + * Delete a personal access token for the current user. + * @throws An error if a non-successful status code (not 200-299) was received. + */ + override async deleteApiKey(apiKeyId: backend.ApiKeyId) { + const path = remoteBackendPaths.deleteApiKeyPath(apiKeyId) + const response = await this.delete(path) + if (!response.ok) { + return await this.throw(response, 'deleteApiKeyBackendError') + } else { + return + } + } + /** * Cancel given subscription. * @throws An error if a non-successful status code (not 200-299) was received. diff --git a/app/gui/src/providers/featureFlags.ts b/app/gui/src/providers/featureFlags.ts index f90ed130be72..824ef698a515 100644 --- a/app/gui/src/providers/featureFlags.ts +++ b/app/gui/src/providers/featureFlags.ts @@ -37,6 +37,7 @@ export const FEATURE_FLAGS_SCHEMA = z.object({ listDirectoryPageSize: z.number().int().min(1), dataCatalogQueryDebounceDelay: z.number().int().min(0), unsafeDarkTheme: z.boolean(), + apiKeyLimit: z.number().int().min(0), debugHoverAreas: z.boolean(), }) @@ -75,6 +76,7 @@ export const flagsStore = createStore()( listDirectoryPageSize: DEFAULT_LIST_DIRECTORY_PAGE_SIZE, dataCatalogQueryDebounceDelay: DEFAULT_DATA_CATALOG_QUERY_DEBOUNCE_DELAY_MS, unsafeDarkTheme: false, + apiKeyLimit: 5, debugHoverAreas: false, }, setFeatureFlag: (key, value) => { diff --git a/app/gui/src/utils/backendQuery.ts b/app/gui/src/utils/backendQuery.ts index 1da90ea74a0a..659506ad8470 100644 --- a/app/gui/src/utils/backendQuery.ts +++ b/app/gui/src/utils/backendQuery.ts @@ -25,6 +25,7 @@ export type BackendMutationMethod = DefineBackendMethods< | 'createDatalink' | 'createDirectory' | 'createPermission' + | 'createApiKey' | 'createProject' | 'createProjectExecution' | 'createSecret' @@ -35,6 +36,7 @@ export type BackendMutationMethod = DefineBackendMethods< | 'deleteAsset' | 'deleteDatalink' | 'deleteInvitation' + | 'deleteApiKey' | 'deleteProjectExecution' | 'deleteTag' | 'deleteUser' @@ -136,6 +138,8 @@ export const INVALIDATION_MAP: Partial< updateProjectExecution: ['listProjectExecutions'], syncProjectExecution: ['listProjectExecutions'], deleteProjectExecution: ['listProjectExecutions'], + createApiKey: ['listApiKeys'], + deleteApiKey: ['listApiKeys'], } /** For each backend method, an optional function defining how to create a query key from its arguments. */ diff --git a/app/gui/tsconfig.node.json b/app/gui/tsconfig.node.json index c8e3f48f1982..df1158200b86 100644 --- a/app/gui/tsconfig.node.json +++ b/app/gui/tsconfig.node.json @@ -39,6 +39,7 @@ "src/project-view/util/shortcuts.ts", "src/providers/featureFlags.ts", "src/providers/session/constants.ts", + "src/utils/detect.ts", "src/utils/uniqueString.ts", "src/utils/zustand.ts", "tailwind.config.ts", diff --git a/eslint.config.mjs b/eslint.config.mjs index 81dd14163b4b..eb7f0b830ba8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -156,11 +156,6 @@ const RESTRICTED_SYNTAXES = [ )`, message: 'Use a `getText()` from `useText` instead of a literal string', }, - { - selector: `JSXAttribute[name.name=/^(?:className)$/] TemplateLiteral`, - message: - 'Use `tv` from `#/utilities/tailwindVariants` or `twMerge` from `tailwind-merge` instead of template strings for classes', - }, { selector: 'JSXOpeningElement[name.name=button] > JSXIdentifier', message: 'Use `Button` or `UnstyledButton` instead of `button`', diff --git a/flake.nix b/flake.nix index c0f722cee5b6..e5bc4b86ef80 100644 --- a/flake.nix +++ b/flake.nix @@ -18,7 +18,7 @@ pkgs2 = nixpkgs2.legacyPackages.${system}; rust = fenix.packages.${system}.fromToolchainFile { dir = ./.; - sha256 = "sha256-IeUO263mdpDxBzWTY7upaZqX+ODkuK1JLTHdR3ItlkY="; + sha256 = "sha256-SJwZ8g0zF2WrKDVmHrVG3pD2RGoQeo24MEXnNx5FyuI="; }; isOnLinux = pkgs.lib.hasInfix "linux" system; rust-jni =