diff --git a/TASKS.md b/TASKS.md index db699d2..84f81d9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -634,12 +634,23 @@ - Error message display for failed actions in lightbox - Uses `GET /api/v1/runs/:id/actions/:actionId/screenshot?token=` endpoint -### ⏳ TODO - Settings Pages +### ✅ DONE - Settings Pages - **Package:** @saveaction/web - **Priority:** P1 - **Labels:** `feature`, `ui` +- **Completed:** 2026-02-09 - **Description:** Build settings pages: API token management (generate, list, revoke), webhook configuration, user profile. +- **Implementation:** + - Created tabbed settings page at `/settings` with Profile, API Tokens, and Security tabs + - Profile tab: Edit display name, view account details (ID, status, member since, email verification) + - API Tokens tab: List tokens with scopes, create new tokens with scope selection and expiry, copy token on creation, revoke/delete tokens + - Security tab: Change password form with validation, password strength indicator, security tips + - Added `PATCH /api/v1/auth/me` endpoint for profile updates + - Created reusable Tabs and Select UI components + - Fixed header dropdown (removed Profile link, kept Settings and Logout) + - Fixed mobile nav menu opening, removed search bar and notification icon + - All features fully functional with proper error handling and toast notifications ### ⏳ TODO - Platform E2E Tests @@ -825,13 +836,13 @@ | Phase 2: CLI | 9 | 7 | 2 | 0 | | Phase 3: API | 33 | 29 | 0 | 4 | | Phase 3.5: CLI Platform (CI/CD) | 5 | 3 | 0 | 2 | -| Phase 4: Web | 10 | 8 | 0 | 2 | +| Phase 4: Web | 10 | 9 | 0 | 1 | | Phase 5: Docker | 5 | 0 | 0 | 5 | | Phase 6: Extension | 3 | 1 | 0 | 2 | | Infrastructure | 3 | 2 | 0 | 1 | | Documentation | 4 | 2 | 0 | 2 | | Backlog | 6 | 0 | 0 | 6 | -| **TOTAL** | **91** | **65** | **2** | **24** | +| **TOTAL** | **91** | **66** | **2** | **23** | ### Test Summary diff --git a/packages/api/src/auth/AuthService.ts b/packages/api/src/auth/AuthService.ts index 674fbe1..ec99a6d 100644 --- a/packages/api/src/auth/AuthService.ts +++ b/packages/api/src/auth/AuthService.ts @@ -311,6 +311,27 @@ export class AuthService { return toUserResponse(user); } + /** + * Update user profile + */ + async updateProfile(userId: string, data: { name?: string }): Promise { + const user = await this.userRepository.findById(userId); + + if (!user) { + throw AuthErrors.USER_NOT_FOUND; + } + + const updated = await this.userRepository.update(userId, { + name: data.name !== undefined ? data.name : user.name, + }); + + if (!updated) { + throw AuthErrors.USER_NOT_FOUND; + } + + return toUserResponse(updated); + } + /** * Verify access token */ diff --git a/packages/api/src/routes/auth.ts b/packages/api/src/routes/auth.ts index 4455f13..5fc306e 100644 --- a/packages/api/src/routes/auth.ts +++ b/packages/api/src/routes/auth.ts @@ -426,6 +426,68 @@ const authRoutes: FastifyPluginAsync = async (fastify, option } ); + /** + * PATCH /auth/me - Update current user profile + */ + fastify.patch<{ Body: { name?: string } }>( + '/me', + { + onRequest: [fastify.authenticate], + schema: { + body: { + type: 'object', + properties: { + name: { type: 'string', maxLength: 255 }, + }, + }, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'object', + properties: { + id: { type: 'string' }, + email: { type: 'string' }, + name: { type: ['string', 'null'] }, + emailVerifiedAt: { type: ['string', 'null'] }, + isActive: { type: 'boolean' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + async (request, reply) => { + try { + const userId = request.jwtPayload?.sub; + + if (!userId) { + return reply.status(401).send({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required', + }, + }); + } + + const user = await authService.updateProfile(userId, request.body); + + return reply.status(200).send({ + success: true, + data: user, + }); + } catch (error) { + return handleAuthError(error, reply); + } + } + ); + /** * POST /auth/change-password - Change password */ diff --git a/packages/web/src/app/(dashboard)/layout.tsx b/packages/web/src/app/(dashboard)/layout.tsx index b5ce841..cc4160d 100644 --- a/packages/web/src/app/(dashboard)/layout.tsx +++ b/packages/web/src/app/(dashboard)/layout.tsx @@ -56,6 +56,14 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) { const [mobileNavOpen, setMobileNavOpen] = React.useState(false); const { isLoading, isAuthenticated } = useAuth(); + const handleMobileNavClose = React.useCallback(() => { + setMobileNavOpen(false); + }, []); + + const handleMobileNavOpen = React.useCallback(() => { + setMobileNavOpen(true); + }, []); + // Show loading skeleton while checking auth if (isLoading) { return ; @@ -72,11 +80,11 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) { {/* Mobile Navigation */} - setMobileNavOpen(false)} /> + {/* Main Content */}
-
setMobileNavOpen(true)} /> +
{children}
diff --git a/packages/web/src/app/(dashboard)/settings/page.tsx b/packages/web/src/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..c750d80 --- /dev/null +++ b/packages/web/src/app/(dashboard)/settings/page.tsx @@ -0,0 +1,127 @@ +'use client'; + +import * as React from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { ApiTokensSettings, ProfileSettings, SecuritySettings } from '@/components/settings'; + +function KeyIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function UserIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function ShieldIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +const TABS = ['profile', 'tokens', 'security'] as const; +type TabValue = (typeof TABS)[number]; + +export default function SettingsPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Get tab from URL or default to 'profile' + const tabParam = searchParams.get('tab'); + const currentTab: TabValue = TABS.includes(tabParam as TabValue) + ? (tabParam as TabValue) + : 'profile'; + + const handleTabChange = (value: string) => { + // Update URL with new tab + const newParams = new URLSearchParams(searchParams.toString()); + newParams.set('tab', value); + router.push(`/settings?${newParams.toString()}`, { scroll: false }); + }; + + return ( +
+ {/* Header */} +
+

Settings

+

Manage your account settings and preferences

+
+ + {/* Settings Tabs */} + + + + + Profile + + + + API Tokens + + + + Security + + + + + + + + + + + + + + + +
+ ); +} diff --git a/packages/web/src/components/layout/header.tsx b/packages/web/src/components/layout/header.tsx index 7ec9fdb..1e3559a 100644 --- a/packages/web/src/components/layout/header.tsx +++ b/packages/web/src/components/layout/header.tsx @@ -2,11 +2,10 @@ import * as React from 'react'; import Link from 'next/link'; -import { Bell, Menu, Search, X, LogOut, Settings, User as UserIcon } from 'lucide-react'; +import { Menu, LogOut, Settings } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { ThemeToggleDropdown } from '@/components/ui/theme-toggle'; import { useAuth, useUser } from '@/components/providers/auth-provider'; @@ -16,7 +15,6 @@ interface HeaderProps { } export function Header({ className, onMenuClick }: HeaderProps) { - const [searchOpen, setSearchOpen] = React.useState(false); const [userMenuOpen, setUserMenuOpen] = React.useState(false); const user = useUser(); const { logout } = useAuth(); @@ -67,33 +65,6 @@ export function Header({ className, onMenuClick }: HeaderProps) { Toggle menu - - {/* Search */} -
-
- - -
-
- - {/* Mobile Search Toggle */} -
{/* Right Section */} @@ -101,15 +72,6 @@ export function Header({ className, onMenuClick }: HeaderProps) { {/* Theme Toggle */} - {/* Notifications */} - - {/* User Menu */}
- setUserMenuOpen(false)} - > - - Profile - - - {/* Mobile Search Bar */} - {searchOpen && ( -
-
- - -
-
- )} ); } diff --git a/packages/web/src/components/layout/mobile-nav.tsx b/packages/web/src/components/layout/mobile-nav.tsx index 5c45ace..e48caff 100644 --- a/packages/web/src/components/layout/mobile-nav.tsx +++ b/packages/web/src/components/layout/mobile-nav.tsx @@ -15,7 +15,7 @@ import { import { cn } from '@/lib/utils'; import { Logo } from './logo'; import { Button } from '@/components/ui/button'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { useAuth, useUser } from '@/components/providers/auth-provider'; interface MobileNavProps { @@ -54,8 +54,11 @@ export function MobileNav({ open, onClose }: MobileNavProps) { // Close on route change React.useEffect(() => { - onClose(); - }, [pathname, onClose]); + if (open) { + onClose(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); // Prevent body scroll when open React.useEffect(() => { @@ -103,7 +106,6 @@ export function MobileNav({ open, onClose }: MobileNavProps) {
- {userInitials}
diff --git a/packages/web/src/components/settings/api-tokens.tsx b/packages/web/src/components/settings/api-tokens.tsx new file mode 100644 index 0000000..8fb629a --- /dev/null +++ b/packages/web/src/components/settings/api-tokens.tsx @@ -0,0 +1,691 @@ +'use client'; + +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogHeader, + DialogTitle, + DialogDescription, + DialogContent, + DialogFooter, + ConfirmDialog, +} from '@/components/ui/dialog'; +import { Select } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { useToast } from '@/components/providers/toast-provider'; +import { api, ApiToken, ApiTokenScope, API_TOKEN_SCOPES, CreateTokenResponse } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +function KeyIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function PlusIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function CopyIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function CheckIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function TrashIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function BanIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +const EXPIRY_OPTIONS = [ + { value: '7', label: '7 days' }, + { value: '30', label: '30 days' }, + { value: '90', label: '90 days' }, + { value: '365', label: '1 year' }, + { value: 'never', label: 'Never' }, +]; + +const SCOPE_DESCRIPTIONS: Record = { + 'recordings:read': 'View recordings', + 'recordings:write': 'Create, update, delete recordings', + 'runs:read': 'View test runs', + 'runs:execute': 'Execute test runs', + 'schedules:read': 'View schedules', + 'schedules:write': 'Create, update, delete schedules', + 'webhooks:read': 'View webhooks', + 'webhooks:write': 'Create, update, delete webhooks', +}; + +function formatDate(date: string | null | undefined): string { + if (!date) return '—'; + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +function formatRelativeTime(date: string | null | undefined): string { + if (!date) return 'Never'; + const now = new Date(); + const then = new Date(date); + const seconds = Math.floor((now.getTime() - then.getTime()) / 1000); + + if (seconds < 60) return 'Just now'; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; + return formatDate(date); +} + +interface CreateTokenFormData { + name: string; + scopes: ApiTokenScope[]; + expiresIn: string; +} + +export function ApiTokensSettings() { + const { addToast } = useToast(); + const [tokens, setTokens] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [isCreating, setIsCreating] = React.useState(false); + const [isDeleting, setIsDeleting] = React.useState(false); + const [isRevoking, setIsRevoking] = React.useState(false); + + // Dialogs state + const [showCreateDialog, setShowCreateDialog] = React.useState(false); + const [showTokenDialog, setShowTokenDialog] = React.useState(false); + const [showRevokeDialog, setShowRevokeDialog] = React.useState(false); + const [showDeleteDialog, setShowDeleteDialog] = React.useState(false); + + // Form state + const [formData, setFormData] = React.useState({ + name: '', + scopes: [], + expiresIn: '30', + }); + + // Created token (shown once) + const [createdToken, setCreatedToken] = React.useState(null); + const [tokenCopied, setTokenCopied] = React.useState(false); + + // Token to revoke/delete + const [selectedToken, setSelectedToken] = React.useState(null); + + // Fetch tokens + const fetchTokens = React.useCallback(async () => { + try { + const response = await api.listApiTokens(); + setTokens(response.tokens); + } catch (error) { + addToast({ + type: 'error', + title: 'Failed to load tokens', + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsLoading(false); + } + }, [addToast]); + + React.useEffect(() => { + fetchTokens(); + }, [fetchTokens]); + + // Create token + const handleCreate = async () => { + if (!formData.name.trim()) { + addToast({ type: 'error', title: 'Token name is required' }); + return; + } + if (formData.scopes.length === 0) { + addToast({ type: 'error', title: 'Select at least one scope' }); + return; + } + + setIsCreating(true); + try { + // Calculate expiration date + let expiresAt: string | null = null; + if (formData.expiresIn !== 'never') { + const days = parseInt(formData.expiresIn, 10); + const date = new Date(); + date.setDate(date.getDate() + days); + expiresAt = date.toISOString(); + } + + const token = await api.createApiToken({ + name: formData.name.trim(), + scopes: formData.scopes, + expiresAt, + }); + setCreatedToken(token); + setShowCreateDialog(false); + setShowTokenDialog(true); + setFormData({ name: '', scopes: [], expiresIn: '30' }); + await fetchTokens(); + } catch (error) { + addToast({ + type: 'error', + title: 'Failed to create token', + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsCreating(false); + } + }; + + // Copy token to clipboard + const handleCopyToken = async () => { + if (!createdToken?.token) return; + try { + await navigator.clipboard.writeText(createdToken.token); + setTokenCopied(true); + setTimeout(() => setTokenCopied(false), 2000); + } catch { + addToast({ type: 'error', title: 'Failed to copy token' }); + } + }; + + // Revoke token + const handleRevoke = async () => { + if (!selectedToken) return; + setIsRevoking(true); + try { + await api.revokeApiToken(selectedToken.id); + addToast({ type: 'success', title: 'Token revoked' }); + setShowRevokeDialog(false); + setSelectedToken(null); + await fetchTokens(); + } catch (error) { + addToast({ + type: 'error', + title: 'Failed to revoke token', + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsRevoking(false); + } + }; + + // Delete token + const handleDelete = async () => { + if (!selectedToken) return; + setIsDeleting(true); + try { + await api.deleteApiToken(selectedToken.id); + addToast({ type: 'success', title: 'Token deleted' }); + setShowDeleteDialog(false); + setSelectedToken(null); + await fetchTokens(); + } catch (error) { + addToast({ + type: 'error', + title: 'Failed to delete token', + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsDeleting(false); + } + }; + + // Toggle scope selection + const toggleScope = (scope: ApiTokenScope) => { + setFormData((prev) => ({ + ...prev, + scopes: prev.scopes.includes(scope) + ? prev.scopes.filter((s) => s !== scope) + : [...prev.scopes, scope], + })); + }; + + // Select all scopes + const selectAllScopes = () => { + setFormData((prev) => ({ + ...prev, + scopes: [...API_TOKEN_SCOPES], + })); + }; + + const activeTokens = tokens.filter((t) => !t.revokedAt); + const revokedTokens = tokens.filter((t) => t.revokedAt); + + if (isLoading) { + return ( + + + + + API Tokens + + Manage API tokens for programmatic access + + +
+
+
+ + + ); + } + + return ( + <> + + +
+
+ + + API Tokens + + Manage API tokens for programmatic access +
+ +
+
+ + {activeTokens.length === 0 && revokedTokens.length === 0 ? ( +
+ +

No API tokens

+

Create a token to access the API programmatically

+
+ ) : ( +
+ {/* Active Tokens */} + {activeTokens.length > 0 && ( +
+

Active Tokens

+
+ {activeTokens.map((token) => ( +
+
+
+
+ {token.name} + + {token.tokenPrefix}...{token.tokenSuffix} + +
+
+ {token.scopes.map((scope) => ( + + {scope} + + ))} +
+
+ Created {formatDate(token.createdAt)} + Last used {formatRelativeTime(token.lastUsedAt)} + {token.expiresAt && ( + + {new Date(token.expiresAt) < new Date() + ? 'Expired' + : `Expires ${formatDate(token.expiresAt)}`} + + )} + Used {token.useCount} times +
+
+
+ + +
+
+
+ ))} +
+
+ )} + + {/* Revoked Tokens */} + {revokedTokens.length > 0 && ( +
+

Revoked Tokens

+
+ {revokedTokens.map((token) => ( +
+
+
+
+ {token.name} + + Revoked + +
+
+ Revoked {formatDate(token.revokedAt)} +
+
+ +
+
+ ))} +
+
+ )} +
+ )} +
+
+ + {/* Create Token Dialog */} + setShowCreateDialog(false)}> + setShowCreateDialog(false)}> + Create API Token + + Generate a new API token for programmatic access + + + +
+
+ + setFormData((prev) => ({ ...prev, name: e.target.value }))} + /> +
+ +
+
+ + +
+
+ {API_TOKEN_SCOPES.map((scope) => ( + + ))} +
+
+ +
+ + setName(e.target.value)} + /> +

+ Your name will be displayed throughout the app +

+
+
+ +
+ +
+

+ Email cannot be changed at this time +

+
+
+ +
+ + {hasChanges && ( + + )} +
+ + + + + {/* Account Details */} + + + + + Account Details + + Your account information and status + + +
+
+ Account ID +

{user?.id}

+
+
+ Account Status +

+ {user?.isActive ? ( + + + Active + + ) : ( + + + Inactive + + )} +

+
+
+ Member Since +

{formatDate(user?.createdAt || null)}

+
+
+ Email Verified +

+ {user?.emailVerifiedAt + ? formatDate(user.emailVerifiedAt) + : 'Not verified'} +

+
+
+
+
+
+ ); +} diff --git a/packages/web/src/components/settings/security.tsx b/packages/web/src/components/settings/security.tsx new file mode 100644 index 0000000..19d14ed --- /dev/null +++ b/packages/web/src/components/settings/security.tsx @@ -0,0 +1,361 @@ +'use client'; + +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useToast } from '@/components/providers/toast-provider'; +import { api, ApiClientError } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +function ShieldIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function EyeIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function EyeOffIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +function CheckCircleIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function XCircleIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +interface PasswordFieldProps { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + error?: string; + autoComplete?: string; +} + +function PasswordField({ label, value, onChange, placeholder, error, autoComplete }: PasswordFieldProps) { + const [showPassword, setShowPassword] = React.useState(false); + + return ( +
+ +
+ onChange(e.target.value)} + placeholder={placeholder} + autoComplete={autoComplete} + className={cn(error && 'border-destructive')} + /> + +
+ {error &&

{error}

} +
+ ); +} + +interface PasswordRequirement { + label: string; + met: boolean; +} + +function PasswordRequirements({ password }: { password: string }) { + const requirements: PasswordRequirement[] = [ + { label: 'At least 8 characters', met: password.length >= 8 }, + { label: 'Contains a number', met: /\d/.test(password) }, + { label: 'Contains a lowercase letter', met: /[a-z]/.test(password) }, + { label: 'Contains an uppercase letter', met: /[A-Z]/.test(password) }, + ]; + + if (!password) return null; + + return ( +
+ {requirements.map((req) => ( +
+ {req.met ? ( + + ) : ( + + )} + + {req.label} + +
+ ))} +
+ ); +} + +export function SecuritySettings() { + const { addToast } = useToast(); + + const [currentPassword, setCurrentPassword] = React.useState(''); + const [newPassword, setNewPassword] = React.useState(''); + const [confirmPassword, setConfirmPassword] = React.useState(''); + const [isLoading, setIsLoading] = React.useState(false); + const [errors, setErrors] = React.useState>({}); + + const validateForm = (): boolean => { + const newErrors: Record = {}; + + if (!currentPassword) { + newErrors.currentPassword = 'Current password is required'; + } + + if (!newPassword) { + newErrors.newPassword = 'New password is required'; + } else if (newPassword.length < 8) { + newErrors.newPassword = 'Password must be at least 8 characters'; + } + + if (!confirmPassword) { + newErrors.confirmPassword = 'Please confirm your new password'; + } else if (newPassword !== confirmPassword) { + newErrors.confirmPassword = 'Passwords do not match'; + } + + if (currentPassword && newPassword && currentPassword === newPassword) { + newErrors.newPassword = 'New password must be different from current password'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) return; + + setIsLoading(true); + setErrors({}); + + try { + await api.changePassword({ currentPassword, newPassword }); + addToast({ + type: 'success', + title: 'Password changed', + description: 'Your password has been updated successfully.', + }); + // Clear form + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + } catch (error) { + if (error instanceof ApiClientError) { + if (error.code === 'INVALID_CREDENTIALS') { + setErrors({ currentPassword: 'Current password is incorrect' }); + } else { + addToast({ + type: 'error', + title: 'Failed to change password', + description: error.message, + }); + } + } else { + addToast({ + type: 'error', + title: 'Failed to change password', + description: 'An unexpected error occurred', + }); + } + } finally { + setIsLoading(false); + } + }; + + const handleReset = () => { + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setErrors({}); + }; + + const hasChanges = currentPassword || newPassword || confirmPassword; + + return ( +
+ {/* Change Password */} + + + + + Change Password + + + Update your password to keep your account secure + + + +
+ + +
+ + +
+ + + +
+ + {hasChanges && ( + + )} +
+ +
+
+ + {/* Security Tips */} + + + Security Tips + + +
    +
  • + + Use a unique password that you don't use for other accounts +
  • +
  • + + Consider using a password manager to generate and store complex passwords +
  • +
  • + + Never share your password or API tokens with others +
  • +
  • + + Regularly review and revoke unused API tokens +
  • +
+
+
+
+ ); +} diff --git a/packages/web/src/components/ui/select.tsx b/packages/web/src/components/ui/select.tsx new file mode 100644 index 0000000..372e211 --- /dev/null +++ b/packages/web/src/components/ui/select.tsx @@ -0,0 +1,149 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +function ChevronDownIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function CheckIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +interface SelectOption { + value: string; + label: string; +} + +interface SelectProps { + value: string; + onValueChange: (value: string) => void; + options: SelectOption[]; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export function Select({ + value, + onValueChange, + options, + placeholder = 'Select...', + disabled = false, + className, +}: SelectProps) { + const [open, setOpen] = React.useState(false); + const containerRef = React.useRef(null); + + const selectedOption = options.find((opt) => opt.value === value); + + // Close on outside click + React.useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + + if (open) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [open]); + + // Close on escape + React.useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + + if (open) { + document.addEventListener('keydown', handleEscape); + } + + return () => { + document.removeEventListener('keydown', handleEscape); + }; + }, [open]); + + return ( +
+ + + {open && ( +
+
+ {options.map((option) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/packages/web/src/components/ui/tabs.tsx b/packages/web/src/components/ui/tabs.tsx new file mode 100644 index 0000000..43ebb5b --- /dev/null +++ b/packages/web/src/components/ui/tabs.tsx @@ -0,0 +1,110 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +interface TabsContextValue { + value: string; + onValueChange: (value: string) => void; +} + +const TabsContext = React.createContext(null); + +function useTabsContext() { + const context = React.useContext(TabsContext); + if (!context) { + throw new Error('Tabs components must be used within a Tabs provider'); + } + return context; +} + +interface TabsProps { + value: string; + onValueChange: (value: string) => void; + className?: string; + children: React.ReactNode; +} + +export function Tabs({ value, onValueChange, className, children }: TabsProps) { + return ( + +
{children}
+
+ ); +} + +interface TabsListProps { + className?: string; + children: React.ReactNode; +} + +export function TabsList({ className, children }: TabsListProps) { + return ( +
+ {children} +
+ ); +} + +interface TabsTriggerProps { + value: string; + className?: string; + children: React.ReactNode; + disabled?: boolean; +} + +export function TabsTrigger({ value, className, children, disabled }: TabsTriggerProps) { + const { value: selectedValue, onValueChange } = useTabsContext(); + const isSelected = selectedValue === value; + + return ( + + ); +} + +interface TabsContentProps { + value: string; + className?: string; + children: React.ReactNode; +} + +export function TabsContent({ value, className, children }: TabsContentProps) { + const { value: selectedValue } = useTabsContext(); + + if (selectedValue !== value) { + return null; + } + + return ( +
+ {children} +
+ ); +} diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 869bb58..717cabd 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -155,6 +155,45 @@ export interface Schedule { updatedAt?: string; } +// API Token Types +export const API_TOKEN_SCOPES = [ + 'recordings:read', + 'recordings:write', + 'runs:read', + 'runs:execute', + 'schedules:read', + 'schedules:write', + 'webhooks:read', + 'webhooks:write', +] as const; + +export type ApiTokenScope = (typeof API_TOKEN_SCOPES)[number]; + +export interface ApiToken { + id: string; + name: string; + tokenPrefix: string; + tokenSuffix: string; + scopes: string[]; + lastUsedAt?: string | null; + useCount: number; + expiresAt?: string | null; + revokedAt?: string | null; + revokedReason?: string | null; + createdAt: string; +} + +export interface CreateTokenResponse { + id: string; + name: string; + token: string; // Full token - only shown once on creation + tokenPrefix: string; + tokenSuffix: string; + scopes: string[]; + expiresAt?: string | null; + createdAt: string; +} + export interface PaginatedResponse { data: T[]; pagination: { @@ -738,6 +777,72 @@ class ApiClient { method: 'DELETE', }); } + + // ===================== + // API Tokens API + // ===================== + + /** + * List API tokens + */ + async listApiTokens(active?: boolean): Promise<{ tokens: ApiToken[]; total: number }> { + const query = active !== undefined ? `?active=${active}` : ''; + return this.request<{ tokens: ApiToken[]; total: number }>(`/api/v1/tokens${query}`); + } + + /** + * Get a single API token + */ + async getApiToken(id: string): Promise { + return this.request(`/api/v1/tokens/${id}`); + } + + /** + * Create a new API token + */ + async createApiToken(data: { + name: string; + scopes: string[]; + expiresAt?: string | null; + }): Promise { + return this.request('/api/v1/tokens', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + /** + * Revoke an API token + */ + async revokeApiToken(id: string, reason?: string): Promise { + return this.request(`/api/v1/tokens/${id}/revoke`, { + method: 'POST', + body: JSON.stringify({ reason }), + }); + } + + /** + * Delete an API token + */ + async deleteApiToken(id: string): Promise { + return this.request(`/api/v1/tokens/${id}`, { + method: 'DELETE', + }); + } + + // ===================== + // User Profile API + // ===================== + + /** + * Update user profile + */ + async updateProfile(data: { name?: string }): Promise { + return this.request('/api/v1/auth/me', { + method: 'PATCH', + body: JSON.stringify(data), + }); + } } /**