Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions packages/api/src/auth/AuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,27 @@ export class AuthService {
return toUserResponse(user);
}

/**
* Update user profile
*/
async updateProfile(userId: string, data: { name?: string }): Promise<UserResponse> {
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
*/
Expand Down
62 changes: 62 additions & 0 deletions packages/api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,68 @@ const authRoutes: FastifyPluginAsync<AuthRoutesOptions> = 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
*/
Expand Down
12 changes: 10 additions & 2 deletions packages/web/src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <DashboardSkeleton />;
Expand All @@ -72,11 +80,11 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
<Sidebar className="hidden lg:flex" />

{/* Mobile Navigation */}
<MobileNav open={mobileNavOpen} onClose={() => setMobileNavOpen(false)} />
<MobileNav open={mobileNavOpen} onClose={handleMobileNavClose} />

{/* Main Content */}
<div className="flex flex-1 flex-col overflow-hidden">
<Header onMenuClick={() => setMobileNavOpen(true)} />
<Header onMenuClick={handleMobileNavOpen} />
<main className="flex-1 overflow-y-auto bg-background-secondary p-4 lg:p-6">
{children}
</main>
Expand Down
127 changes: 127 additions & 0 deletions packages/web/src/app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="7.5" cy="15.5" r="5.5" />
<path d="m21 2-9.6 9.6" />
<path d="m15.5 7.5 3 3L22 7l-3-3" />
</svg>
);
}

function UserIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="8" r="5" />
<path d="M20 21a8 8 0 0 0-16 0" />
</svg>
);
}

function ShieldIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
</svg>
);
}

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 (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground">Manage your account settings and preferences</p>
</div>

{/* Settings Tabs */}
<Tabs value={currentTab} onValueChange={handleTabChange}>
<TabsList className="grid w-full grid-cols-3 lg:w-auto lg:inline-flex">
<TabsTrigger value="profile" className="gap-2">
<UserIcon className="h-4 w-4" />
<span className="hidden sm:inline">Profile</span>
</TabsTrigger>
<TabsTrigger value="tokens" className="gap-2">
<KeyIcon className="h-4 w-4" />
<span className="hidden sm:inline">API Tokens</span>
</TabsTrigger>
<TabsTrigger value="security" className="gap-2">
<ShieldIcon className="h-4 w-4" />
<span className="hidden sm:inline">Security</span>
</TabsTrigger>
</TabsList>

<TabsContent value="profile" className="mt-6">
<ProfileSettings />
</TabsContent>

<TabsContent value="tokens" className="mt-6">
<ApiTokensSettings />
</TabsContent>

<TabsContent value="security" className="mt-6">
<SecuritySettings />
</TabsContent>
</Tabs>
</div>
);
}
Loading