From ae64c63fa485f855cdfecceeba39be2103d8b2b8 Mon Sep 17 00:00:00 2001 From: idoshamun Date: Sun, 1 Feb 2026 15:43:31 +0000 Subject: [PATCH 1/3] feat(settings): add API Access page for Plus token management This implements the frontend for ENG-532: API access for Plus users. New components: - API settings page at /settings/api for token management - Create token modal with name and expiration options - Token created modal with copy-to-clipboard functionality - Token list with revoke capability Features: - GraphQL hooks for token CRUD operations - Plus subscription gate (shows upgrade prompt for non-Plus) - Copy AI agent instruction to clipboard - Link to API documentation Navigation: - Add "API Access" item to settings menu under Customization Co-Authored-By: Claude Opus 4.5 --- .../profile/ProfileSettingsMenu.tsx | 5 + .../src/graphql/personalAccessTokens.ts | 65 +++ .../src/hooks/api/usePersonalAccessTokens.ts | 73 +++ packages/shared/src/lib/query.ts | 1 + packages/webapp/pages/settings/api.tsx | 452 ++++++++++++++++++ 5 files changed, 596 insertions(+) create mode 100644 packages/shared/src/graphql/personalAccessTokens.ts create mode 100644 packages/shared/src/hooks/api/usePersonalAccessTokens.ts create mode 100644 packages/webapp/pages/settings/api.tsx diff --git a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx index 57a43eb39d..a34258f109 100644 --- a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx +++ b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx @@ -244,6 +244,11 @@ const useAccountPageItems = () => { icon: EmbedIcon, href: `${settingsUrl}/customization/integrations`, }, + api: { + title: 'API Access', + icon: TerminalIcon, + href: `${settingsUrl}/api`, + }, }, }, help: { diff --git a/packages/shared/src/graphql/personalAccessTokens.ts b/packages/shared/src/graphql/personalAccessTokens.ts new file mode 100644 index 0000000000..78ff6b62d7 --- /dev/null +++ b/packages/shared/src/graphql/personalAccessTokens.ts @@ -0,0 +1,65 @@ +import { gql } from 'graphql-request'; + +export interface PersonalAccessToken { + id: string; + name: string; + tokenPrefix: string; + createdAt: Date; + expiresAt: Date | null; + lastUsedAt: Date | null; +} + +export interface PersonalAccessTokenCreated extends PersonalAccessToken { + token: string; +} + +export interface PersonalAccessTokensData { + personalAccessTokens: PersonalAccessToken[]; +} + +export interface CreatePersonalAccessTokenData { + createPersonalAccessToken: PersonalAccessTokenCreated; +} + +export interface CreatePersonalAccessTokenInput { + name: string; + expiresInDays?: number | null; +} + +export interface RevokePersonalAccessTokenData { + revokePersonalAccessToken: { _: boolean }; +} + +export const PERSONAL_ACCESS_TOKENS_QUERY = gql` + query PersonalAccessTokens { + personalAccessTokens { + id + name + tokenPrefix + createdAt + expiresAt + lastUsedAt + } + } +`; + +export const CREATE_PERSONAL_ACCESS_TOKEN_MUTATION = gql` + mutation CreatePersonalAccessToken($input: CreatePersonalAccessTokenInput!) { + createPersonalAccessToken(input: $input) { + id + name + token + tokenPrefix + createdAt + expiresAt + } + } +`; + +export const REVOKE_PERSONAL_ACCESS_TOKEN_MUTATION = gql` + mutation RevokePersonalAccessToken($id: ID!) { + revokePersonalAccessToken(id: $id) { + _ + } + } +`; diff --git a/packages/shared/src/hooks/api/usePersonalAccessTokens.ts b/packages/shared/src/hooks/api/usePersonalAccessTokens.ts new file mode 100644 index 0000000000..ed16653c6c --- /dev/null +++ b/packages/shared/src/hooks/api/usePersonalAccessTokens.ts @@ -0,0 +1,73 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { gqlClient } from '../../graphql/common'; +import { + PERSONAL_ACCESS_TOKENS_QUERY, + CREATE_PERSONAL_ACCESS_TOKEN_MUTATION, + REVOKE_PERSONAL_ACCESS_TOKEN_MUTATION, +} from '../../graphql/personalAccessTokens'; +import type { + PersonalAccessTokensData, + CreatePersonalAccessTokenData, + CreatePersonalAccessTokenInput, + RevokePersonalAccessTokenData, +} from '../../graphql/personalAccessTokens'; +import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query'; +import { useAuthContext } from '../../contexts/AuthContext'; + +export const personalAccessTokensQueryOptions = () => ({ + queryKey: generateQueryKey(RequestKey.PersonalAccessTokens), + queryFn: async () => { + const data = await gqlClient.request( + PERSONAL_ACCESS_TOKENS_QUERY, + ); + return data.personalAccessTokens; + }, + staleTime: StaleTime.OneMinute, +}); + +export const usePersonalAccessTokens = () => { + const { user } = useAuthContext(); + + return useQuery({ + ...personalAccessTokensQueryOptions(), + enabled: !!user, + }); +}; + +export const useCreatePersonalAccessToken = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CreatePersonalAccessTokenInput) => { + const data = await gqlClient.request( + CREATE_PERSONAL_ACCESS_TOKEN_MUTATION, + { input }, + ); + return data.createPersonalAccessToken; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.PersonalAccessTokens), + }); + }, + }); +}; + +export const useRevokePersonalAccessToken = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const data = await gqlClient.request( + REVOKE_PERSONAL_ACCESS_TOKEN_MUTATION, + { id }, + ); + return data.revokePersonalAccessToken; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.PersonalAccessTokens), + }); + }, + }); +}; diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 838d236596..bf95982f7c 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -239,6 +239,7 @@ export enum RequestKey { UserWorkspacePhotos = 'user_workspace_photos', Gear = 'gear', GearSearch = 'gear_search', + PersonalAccessTokens = 'personal_access_tokens', } export const getPostByIdKey = (id: string): QueryKey => [RequestKey.Post, id]; diff --git a/packages/webapp/pages/settings/api.tsx b/packages/webapp/pages/settings/api.tsx new file mode 100644 index 0000000000..80df4da3a7 --- /dev/null +++ b/packages/webapp/pages/settings/api.tsx @@ -0,0 +1,452 @@ +import React, { useState } from 'react'; +import type { ReactElement } from 'react'; +import type { NextSeoProps } from 'next-seo'; +import { + usePlusSubscription, + useViewSize, + ViewSize, +} from '@dailydotdev/shared/src/hooks'; +import { + usePersonalAccessTokens, + useCreatePersonalAccessToken, + useRevokePersonalAccessToken, +} from '@dailydotdev/shared/src/hooks/api/usePersonalAccessTokens'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + PlusIcon, + CopyIcon, + TrashIcon, + LockIcon, + BookIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { TextField } from '@dailydotdev/shared/src/components/fields/TextField'; +import { + Modal, + ModalSize, +} from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { ModalHeader } from '@dailydotdev/shared/src/components/modals/common/ModalHeader'; +import { ModalBody } from '@dailydotdev/shared/src/components/modals/common/ModalBody'; +import { ModalFooter } from '@dailydotdev/shared/src/components/modals/common/ModalFooter'; +import { Radio } from '@dailydotdev/shared/src/components/fields/Radio'; +import { formatDate } from '@dailydotdev/shared/src/lib/dateFormat'; +import { AccountPageContainer } from '../../components/layouts/SettingsLayout/AccountPageContainer'; +import { getSettingsLayout } from '../../components/layouts/SettingsLayout'; +import { defaultSeo } from '../../next-seo'; +import { getTemplatedTitle } from '../../components/layouts/utils'; + +const seo: NextSeoProps = { + ...defaultSeo, + title: getTemplatedTitle('API Access'), +}; + +const SKILL_INSTRUCTION = + 'Read https://daily.dev/skill.md and follow the instructions to connect to daily.dev'; +const DOCS_URL = 'https://docs.daily.dev/api'; + +const ExpirationOptions = [ + { value: '', label: 'Never expires' }, + { value: '30', label: '30 days' }, + { value: '90', label: '90 days' }, + { value: '365', label: '1 year' }, +]; + +interface CreateTokenModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (token: string) => void; +} + +const CreateTokenModal = ({ + isOpen, + onClose, + onSuccess, +}: CreateTokenModalProps): ReactElement => { + const [name, setName] = useState(''); + const [expiration, setExpiration] = useState(''); + const { mutateAsync: createToken, isPending } = + useCreatePersonalAccessToken(); + const { displayToast } = useToastNotification(); + + const handleCreate = async () => { + if (!name.trim()) { + displayToast('Please enter a token name'); + return; + } + + try { + const result = await createToken({ + name: name.trim(), + expiresInDays: expiration ? parseInt(expiration, 10) : null, + }); + onSuccess(result.token); + setName(''); + setExpiration(''); + } catch { + displayToast('Failed to create token. Please try again.'); + } + }; + + return ( + + + + setName(e.target.value)} + placeholder="e.g., My AI Agent" + maxLength={50} + /> +
+ + Expiration + + {ExpirationOptions.map((option) => ( + setExpiration(option.value)} + > + {option.label} + + ))} +
+
+ + + + +
+ ); +}; + +interface TokenCreatedModalProps { + isOpen: boolean; + token: string; + onClose: () => void; +} + +const TokenCreatedModal = ({ + isOpen, + token, + onClose, +}: TokenCreatedModalProps): ReactElement => { + const { displayToast } = useToastNotification(); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(token); + displayToast('Token copied to clipboard'); + } catch { + displayToast('Failed to copy token'); + } + }; + + return ( + + + +
+ + + This token will only be shown once. Copy it now! + +
+
+ {token} +
+
+ + + +
+ ); +}; + +interface TokenListItemProps { + id: string; + name: string; + tokenPrefix: string; + createdAt: Date; + lastUsedAt: Date | null; + onRevoke: (id: string) => void; +} + +const TokenListItem = ({ + id, + name, + tokenPrefix, + createdAt, + lastUsedAt, + onRevoke, +}: TokenListItemProps): ReactElement => { + return ( +
+
+ + {name} + + + {tokenPrefix}... + + + Created {formatDate(createdAt)} + {lastUsedAt && ` | Last used ${formatDate(lastUsedAt)}`} + +
+
+ ); +}; + +const ApiAccessPage = (): ReactElement => { + const { isPlus } = usePlusSubscription(); + const { data: tokens, isLoading } = usePersonalAccessTokens(); + const { mutateAsync: revokeToken } = useRevokePersonalAccessToken(); + const { displayToast } = useToastNotification(); + const isMobile = useViewSize(ViewSize.MobileL); + + const [showCreateModal, setShowCreateModal] = useState(false); + const [createdToken, setCreatedToken] = useState(null); + + const handleCopyInstruction = async () => { + try { + await navigator.clipboard.writeText(SKILL_INSTRUCTION); + displayToast('Instruction copied to clipboard'); + } catch { + displayToast('Failed to copy'); + } + }; + + const handleRevoke = async (id: string) => { + try { + await revokeToken(id); + displayToast('Token revoked successfully'); + } catch { + displayToast('Failed to revoke token'); + } + }; + + if (!isPlus) { + return ( + +
+ + Plus Feature + + + API access is available exclusively for Plus subscribers. Upgrade to + connect AI agents and automate your workflows. + + +
+
+ ); + } + + return ( + } + onClick={() => setShowCreateModal(true)} + > + {isMobile ? '' : 'Create token'} + + } + > +
+
+ + Personal Access Tokens + + + Use tokens to authenticate with the daily.dev API. Tokens provide + read-only access to your personalized feed and posts. + +
+ + {isLoading && ( + + Loading tokens... + + )} + + {!isLoading && tokens && tokens.length > 0 && ( +
+ {tokens.map((token) => ( + + ))} +
+ )} + + {!isLoading && (!tokens || tokens.length === 0) && ( +
+ + No tokens yet. Create one to get started. + + +
+ )} + +
+ + Connect Your AI Agent + + + Copy this instruction to your AI agent to get started: + +
+ + {SKILL_INSTRUCTION} + +
+
+ +
+ + Documentation + + + Learn how to use the API with detailed documentation and examples. + + +
+
+ + setShowCreateModal(false)} + onSuccess={(token) => { + setShowCreateModal(false); + setCreatedToken(token); + }} + /> + + setCreatedToken(null)} + /> +
+ ); +}; + +ApiAccessPage.getLayout = getSettingsLayout; +ApiAccessPage.layoutProps = { seo }; + +export default ApiAccessPage; From 78979e5b5f60919decf864a07f68848686beb02d Mon Sep 17 00:00:00 2001 From: idoshamun Date: Sun, 1 Feb 2026 16:17:12 +0000 Subject: [PATCH 2/3] fix(settings): correct imports and component usage in API Access page - Replace BookIcon with DocsIcon (BookIcon doesn't exist) - Fix ModalSize import from correct module (types.ts) - Fix Radio component usage to use options array pattern - Fix formatDate calls to use object format with TimeFormatType Co-Authored-By: Claude Opus 4.5 --- packages/webapp/pages/settings/api.tsx | 40 +++++++++++++------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/webapp/pages/settings/api.tsx b/packages/webapp/pages/settings/api.tsx index 80df4da3a7..6913c42aaa 100644 --- a/packages/webapp/pages/settings/api.tsx +++ b/packages/webapp/pages/settings/api.tsx @@ -27,19 +27,20 @@ import { CopyIcon, TrashIcon, LockIcon, - BookIcon, + DocsIcon, } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { TextField } from '@dailydotdev/shared/src/components/fields/TextField'; -import { - Modal, - ModalSize, -} from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { ModalSize } from '@dailydotdev/shared/src/components/modals/common/types'; import { ModalHeader } from '@dailydotdev/shared/src/components/modals/common/ModalHeader'; import { ModalBody } from '@dailydotdev/shared/src/components/modals/common/ModalBody'; import { ModalFooter } from '@dailydotdev/shared/src/components/modals/common/ModalFooter'; import { Radio } from '@dailydotdev/shared/src/components/fields/Radio'; -import { formatDate } from '@dailydotdev/shared/src/lib/dateFormat'; +import { + formatDate, + TimeFormatType, +} from '@dailydotdev/shared/src/lib/dateFormat'; import { AccountPageContainer } from '../../components/layouts/SettingsLayout/AccountPageContainer'; import { getSettingsLayout } from '../../components/layouts/SettingsLayout'; import { defaultSeo } from '../../next-seo'; @@ -114,17 +115,12 @@ const CreateTokenModal = ({ Expiration - {ExpirationOptions.map((option) => ( - setExpiration(option.value)} - > - {option.label} - - ))} + @@ -237,8 +233,12 @@ const TokenListItem = ({ type={TypographyType.Footnote} color={TypographyColor.Tertiary} > - Created {formatDate(createdAt)} - {lastUsedAt && ` | Last used ${formatDate(lastUsedAt)}`} + Created {formatDate({ value: createdAt, type: TimeFormatType.Post })} + {lastUsedAt && + ` | Last used ${formatDate({ + value: lastUsedAt, + type: TimeFormatType.Post, + })}`} } > @@ -391,8 +406,8 @@ const ApiAccessPage = (): ReactElement => { > Copy this instruction to your AI agent to get started: -
- +
+ {SKILL_INSTRUCTION}
- Documentation + OpenAPI - Learn how to use the API with detailed documentation and examples. + View the OpenAPI specification to explore available endpoints and + integrate with your tools.