feat(settings): Rework user settings page with Form, loading skeleton… (#9476)

Implemented a fully functional user settings page allowing changes to
account details and notification preferences. This change uses a server
first approach and adds much needed form validation to this page.
This PR has added loading skeletons for better UX during data fetching.
Refactored related components to support these changes and finally
implemented server actions to streamline data ingestion.

## Note to developers:
At the moment the notification switches set back to default upon save.
We will want to pass in this information after the api is implemented.


## Changes 🏗️
Rebuilt / Refactored `SettingsFormInput` to `SettingsForm`:
- Implemented Form Validation 
- Implemented a form schema with Zod to validate user input
- Added toast messaging to properly inform the user if the form has been
successfully completed or if there is an error in thrown from the server
action.

Added `loading.tsx`
- Using `Skeletons` we can deliver a better loading UI for our users
causing less screen shifting.

Added `actions.ts`
- Added a server action for the settings page. This server action will
handle the updating of user's settings for this page. It handles the
interaction between the application and supabase. After this server
action is ran we revalidate the path for our settings page ensuring
proper data passed to our components.

There is an additional TODO for @ntindle for the api endpoint getting
created. This endpoint will cover the newly added notification switches
and it's toggles.

## Screenshots 📷
### Before Changes:
<img width="1083" alt="image"
src="https://github.com/user-attachments/assets/f5283fd5-705b-47cf-a7fa-4ca4d7f03444"
/>


### After Changes:
<img width="762" alt="image"
src="https://github.com/user-attachments/assets/20f96f01-b138-4eb7-8867-ce62a2d603d4"
/>

<img width="1083" alt="image"
src="https://github.com/user-attachments/assets/0ae363f5-068f-48e5-8b0f-c079a08f9242"
/>

<img width="1083" alt="image"
src="https://github.com/user-attachments/assets/8cb045ef-f322-4992-881e-fb92281c55cb"
/>

#### Form Validation
<img width="1083" alt="image"
src="https://github.com/user-attachments/assets/b78cfef6-94da-49f1-9c93-56cdb9ea4c96"
/>
<img width="1083" alt="image"
src="https://github.com/user-attachments/assets/ade5dce9-8c4b-40eb-aa0f-ff6d31bc3c3c"
/>
<img width="245" alt="image"
src="https://github.com/user-attachments/assets/88866bbf-4e33-43d9-b04a-b53ac848852d"
/>



### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:


<details>
  <summary>Test Plan</summary>
 
  - [ ] Goto the route of `profile/settings`
  - [ ] Add an invalid email and notice the new validation messaging
- [ ] Add invalid passwords that do not match and or is under 8
characters notice the new validation messaging
- [ ] Select the cancel button and notice that the form has been set
back to the default values
- [ ] With the form untouched notice the `Save changes` button is
disabled. Toggle a switch and notice the `Save changes` button is now
enabled.
- [ ] Enter in a valid pair of new passwords in the `New Password` and
`Confirm New Password` input fields and select `Save changes`
- [ ] Enter in the same passwords again and notice that we will now be
shown an Error. This error is bubbling up from supabase in our backend
and is stating `New password should be different from the old password`
</details>

---------

Co-authored-by: Nicholas Tindle <nicholas.tindle@agpt.co>
Co-authored-by: Nicholas Tindle <nicktindle@outlook.com>
Co-authored-by: Reinier van der Leer <pwuts@agpt.co>
This commit is contained in:
Andy Hooker
2025-02-19 08:51:37 -06:00
committed by GitHub
parent d1832ce10b
commit a0be165835
13 changed files with 769 additions and 175 deletions

View File

@@ -714,9 +714,11 @@ async def fix_llm_provider_credentials():
store = IntegrationCredentialsStore()
broken_nodes = await prisma.get_client().query_raw(
"""
SELECT graph."userId" user_id,
broken_nodes = []
try:
broken_nodes = await prisma.get_client().query_raw(
"""
SELECT graph."userId" user_id,
node.id node_id,
node."constantInput" node_preset_input
FROM platform."AgentNode" node
@@ -725,8 +727,10 @@ async def fix_llm_provider_credentials():
WHERE node."constantInput"::jsonb->'credentials'->>'provider' = 'llm'
ORDER BY graph."userId";
"""
)
logger.info(f"Fixing LLM credential inputs on {len(broken_nodes)} nodes")
)
logger.info(f"Fixing LLM credential inputs on {len(broken_nodes)} nodes")
except Exception as e:
logger.error(f"Error fixing LLM credential inputs: {e}")
user_id: str = ""
user_integrations = None

View File

@@ -217,6 +217,14 @@ class NotificationTypeOverride:
}[self.notification_type]
class NotificationPreferenceDTO(BaseModel):
email: EmailStr = Field(..., description="User's email address")
preferences: dict[NotificationType, bool] = Field(
..., description="Which notifications the user wants"
)
daily_limit: int = Field(..., description="Max emails per day")
class NotificationPreference(BaseModel):
user_id: str
email: EmailStr

View File

@@ -7,10 +7,11 @@ from fastapi import HTTPException
from prisma import Json
from prisma.enums import NotificationType
from prisma.models import User
from prisma.types import UserUpdateInput
from backend.data.db import prisma
from backend.data.model import UserIntegrations, UserMetadata, UserMetadataRaw
from backend.data.notifications import NotificationPreference
from backend.data.notifications import NotificationPreference, NotificationPreferenceDTO
from backend.server.v2.store.exceptions import DatabaseError
from backend.util.encryption import JSONCryptor
@@ -57,6 +58,15 @@ async def get_user_email_by_id(user_id: str) -> str:
raise DatabaseError(f"Failed to get user email for user {user_id}: {e}") from e
async def update_user_email(user_id: str, email: str):
try:
await prisma.user.update(where={"id": user_id}, data={"email": email})
except Exception as e:
raise DatabaseError(
f"Failed to update user email for user {user_id}: {e}"
) from e
async def create_default_user() -> Optional[User]:
user = await prisma.user.find_unique(where={"id": DEFAULT_USER_ID})
if not user:
@@ -186,16 +196,16 @@ async def get_user_notification_preference(user_id: str) -> NotificationPreferen
# enable notifications by default if user has no notification preference (shouldn't ever happen though)
preferences: dict[NotificationType, bool] = {
NotificationType.AGENT_RUN: user.notifyOnAgentRun or True,
NotificationType.ZERO_BALANCE: user.notifyOnZeroBalance or True,
NotificationType.LOW_BALANCE: user.notifyOnLowBalance or True,
NotificationType.AGENT_RUN: user.notifyOnAgentRun or False,
NotificationType.ZERO_BALANCE: user.notifyOnZeroBalance or False,
NotificationType.LOW_BALANCE: user.notifyOnLowBalance or False,
NotificationType.BLOCK_EXECUTION_FAILED: user.notifyOnBlockExecutionFailed
or True,
or False,
NotificationType.CONTINUOUS_AGENT_ERROR: user.notifyOnContinuousAgentError
or True,
NotificationType.DAILY_SUMMARY: user.notifyOnDailySummary or True,
NotificationType.WEEKLY_SUMMARY: user.notifyOnWeeklySummary or True,
NotificationType.MONTHLY_SUMMARY: user.notifyOnMonthlySummary or True,
or False,
NotificationType.DAILY_SUMMARY: user.notifyOnDailySummary or False,
NotificationType.WEEKLY_SUMMARY: user.notifyOnWeeklySummary or False,
NotificationType.MONTHLY_SUMMARY: user.notifyOnMonthlySummary or False,
}
daily_limit = user.maxEmailsPerDay or 3
notification_preference = NotificationPreference(
@@ -213,3 +223,80 @@ async def get_user_notification_preference(user_id: str) -> NotificationPreferen
raise DatabaseError(
f"Failed to upsert user notification preference for user {user_id}: {e}"
) from e
async def update_user_notification_preference(
user_id: str, data: NotificationPreferenceDTO
) -> NotificationPreference:
try:
update_data: UserUpdateInput = {}
if data.email:
update_data["email"] = data.email
if NotificationType.AGENT_RUN in data.preferences:
update_data["notifyOnAgentRun"] = data.preferences[
NotificationType.AGENT_RUN
]
if NotificationType.ZERO_BALANCE in data.preferences:
update_data["notifyOnZeroBalance"] = data.preferences[
NotificationType.ZERO_BALANCE
]
if NotificationType.LOW_BALANCE in data.preferences:
update_data["notifyOnLowBalance"] = data.preferences[
NotificationType.LOW_BALANCE
]
if NotificationType.BLOCK_EXECUTION_FAILED in data.preferences:
update_data["notifyOnBlockExecutionFailed"] = data.preferences[
NotificationType.BLOCK_EXECUTION_FAILED
]
if NotificationType.CONTINUOUS_AGENT_ERROR in data.preferences:
update_data["notifyOnContinuousAgentError"] = data.preferences[
NotificationType.CONTINUOUS_AGENT_ERROR
]
if NotificationType.DAILY_SUMMARY in data.preferences:
update_data["notifyOnDailySummary"] = data.preferences[
NotificationType.DAILY_SUMMARY
]
if NotificationType.WEEKLY_SUMMARY in data.preferences:
update_data["notifyOnWeeklySummary"] = data.preferences[
NotificationType.WEEKLY_SUMMARY
]
if NotificationType.MONTHLY_SUMMARY in data.preferences:
update_data["notifyOnMonthlySummary"] = data.preferences[
NotificationType.MONTHLY_SUMMARY
]
if data.daily_limit:
update_data["maxEmailsPerDay"] = data.daily_limit
user = await User.prisma().update(
where={"id": user_id},
data=update_data,
)
if not user:
raise ValueError(f"User not found with ID: {user_id}")
preferences: dict[NotificationType, bool] = {
NotificationType.AGENT_RUN: user.notifyOnAgentRun or True,
NotificationType.ZERO_BALANCE: user.notifyOnZeroBalance or True,
NotificationType.LOW_BALANCE: user.notifyOnLowBalance or True,
NotificationType.BLOCK_EXECUTION_FAILED: user.notifyOnBlockExecutionFailed
or True,
NotificationType.CONTINUOUS_AGENT_ERROR: user.notifyOnContinuousAgentError
or True,
NotificationType.DAILY_SUMMARY: user.notifyOnDailySummary or True,
NotificationType.WEEKLY_SUMMARY: user.notifyOnWeeklySummary or True,
NotificationType.MONTHLY_SUMMARY: user.notifyOnMonthlySummary or True,
}
notification_preference = NotificationPreference(
user_id=user.id,
email=user.email,
preferences=preferences,
daily_limit=user.maxEmailsPerDay or 3,
# TODO with other changes later, for now we just will email them
emails_sent_today=0,
last_reset_date=datetime.now(),
)
return NotificationPreference.model_validate(notification_preference)
except Exception as e:
raise DatabaseError(
f"Failed to update user notification preference for user {user_id}: {e}"
) from e

View File

@@ -41,7 +41,13 @@ from backend.data.credit import (
get_user_credit_model,
set_auto_top_up,
)
from backend.data.user import get_or_create_user
from backend.data.notifications import NotificationPreference, NotificationPreferenceDTO
from backend.data.user import (
get_or_create_user,
get_user_notification_preference,
update_user_email,
update_user_notification_preference,
)
from backend.executor import ExecutionManager, ExecutionScheduler, scheduler
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.integrations.webhooks.graph_lifecycle_hooks import (
@@ -109,6 +115,42 @@ async def get_or_create_user_route(user_data: dict = Depends(auth_middleware)):
return user.model_dump()
@v1_router.post(
"/auth/user/email", tags=["auth"], dependencies=[Depends(auth_middleware)]
)
async def update_user_email_route(
user_id: Annotated[str, Depends(get_user_id)], email: str = Body(...)
) -> dict[str, str]:
await update_user_email(user_id, email)
return {"email": email}
@v1_router.get(
"/auth/user/preferences",
tags=["auth"],
dependencies=[Depends(auth_middleware)],
)
async def get_preferences(
user_id: Annotated[str, Depends(get_user_id)],
) -> NotificationPreference:
preferences = await get_user_notification_preference(user_id)
return preferences
@v1_router.post(
"/auth/user/preferences",
tags=["auth"],
dependencies=[Depends(auth_middleware)],
)
async def update_preferences(
user_id: Annotated[str, Depends(get_user_id)],
preferences: NotificationPreferenceDTO = Body(...),
) -> NotificationPreference:
output = await update_user_notification_preference(user_id, preferences)
return output
########################################################
##################### Blocks ###########################
########################################################

View File

@@ -0,0 +1,72 @@
"use server";
import { revalidatePath } from "next/cache";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import BackendApi from "@/lib/autogpt-server-api";
import { NotificationPreferenceDTO } from "@/lib/autogpt-server-api/types";
export async function updateSettings(formData: FormData) {
const supabase = getServerSupabase();
const {
data: { user },
} = await supabase.auth.getUser();
// Handle auth-related updates
const password = formData.get("password") as string;
const email = formData.get("email") as string;
if (password) {
const { error: passwordError } = await supabase.auth.updateUser({
password,
});
if (passwordError) {
throw new Error(`${passwordError.message}`);
}
}
if (email !== user?.email) {
const { error: emailError } = await supabase.auth.updateUser({
email,
});
const api = new BackendApi();
await api.updateUserEmail(email);
if (emailError) {
throw new Error(`${emailError.message}`);
}
}
try {
const api = new BackendApi();
const preferences: NotificationPreferenceDTO = {
email: user?.email || "",
preferences: {
AGENT_RUN: formData.get("notifyOnAgentRun") === "true",
ZERO_BALANCE: formData.get("notifyOnZeroBalance") === "true",
LOW_BALANCE: formData.get("notifyOnLowBalance") === "true",
BLOCK_EXECUTION_FAILED:
formData.get("notifyOnBlockExecutionFailed") === "true",
CONTINUOUS_AGENT_ERROR:
formData.get("notifyOnContinuousAgentError") === "true",
DAILY_SUMMARY: formData.get("notifyOnDailySummary") === "true",
WEEKLY_SUMMARY: formData.get("notifyOnWeeklySummary") === "true",
MONTHLY_SUMMARY: formData.get("notifyOnMonthlySummary") === "true",
},
daily_limit: 0,
};
await api.updateUserPreferences(preferences);
} catch (error) {
console.error(error);
throw new Error(`Failed to update preferences: ${error}`);
}
revalidatePath("/profile/settings");
return { success: true };
}
export async function getUserPreferences(): Promise<NotificationPreferenceDTO> {
const api = new BackendApi();
const preferences = await api.getUserPreferences();
return preferences;
}

View File

@@ -0,0 +1,55 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
export default function SettingsLoading() {
return (
<div className="container max-w-2xl py-10">
<div className="space-y-6">
<div>
<Skeleton className="h-6 w-32" />
<Skeleton className="mt-2 h-4 w-96" />
</div>
<div className="space-y-8">
<div className="space-y-4">
{/* Email and Password fields */}
{[1, 2].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
<Separator />
<div className="space-y-4">
<Skeleton className="h-6 w-28" />
{/* Agent Notifications */}
<div className="space-y-4">
<Skeleton className="h-4 w-36" />
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex flex-row items-center justify-between rounded-lg p-4"
>
<div className="space-y-0.5">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-6 w-11" />
</div>
))}
</div>
</div>
<div className="flex items-center justify-end space-x-4">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-32" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,32 @@
import * as React from "react";
import { SettingsInputForm } from "@/components/agptui/SettingsInputForm";
import { Metadata } from "next";
import SettingsForm from "@/components/profile/settings/SettingsForm";
import getServerUser from "@/lib/supabase/getServerUser";
import { redirect } from "next/navigation";
import { getUserPreferences } from "./actions";
export const metadata: Metadata = {
title: "Settings",
description: "Manage your account settings and preferences.",
};
export default function Page() {
return <SettingsInputForm />;
export default async function SettingsPage() {
const { user, error } = await getServerUser();
if (error || !user) {
redirect("/login");
}
const preferences = await getUserPreferences();
return (
<div className="container max-w-2xl space-y-6 py-10">
<div>
<h3 className="text-lg font-medium">My account</h3>
<p className="text-sm text-muted-foreground">
Manage your account settings and preferences.
</p>
</div>
<SettingsForm user={user} preferences={preferences} />
</div>
);
}

View File

@@ -1,23 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { SettingsInputForm } from "./SettingsInputForm";
const meta: Meta<typeof SettingsInputForm> = {
title: "AGPT UI/Settings/Settings Input Form",
component: SettingsInputForm,
parameters: {
layout: "fullscreen",
},
};
export default meta;
type Story = StoryObj<typeof SettingsInputForm>;
export const Default: Story = {
args: {
email: "johndoe@email.com",
desktopNotifications: {
first: false,
second: true,
},
},
};

View File

@@ -1,134 +0,0 @@
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import useSupabase from "@/hooks/useSupabase";
interface SettingsInputFormProps {
email?: string;
desktopNotifications?: {
first: boolean;
second: boolean;
};
}
export const SettingsInputForm = ({
email = "johndoe@email.com",
desktopNotifications = { first: false, second: true },
}: SettingsInputFormProps) => {
const [password, setPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const [passwordsMatch, setPasswordsMatch] = React.useState(true);
const { supabase } = useSupabase();
const handleSaveChanges = async () => {
if (password !== confirmPassword) {
setPasswordsMatch(false);
return;
}
setPasswordsMatch(true);
if (supabase) {
try {
const { error } = await supabase.auth.updateUser({
password: password,
});
if (error) {
console.error("Error updating user:", error);
} else {
console.log("User updated successfully");
}
} catch (error) {
console.error("Error updating user:", error);
}
}
};
const handleCancel = () => {
setPassword("");
setConfirmPassword("");
setPasswordsMatch(true);
};
return (
<div className="mx-auto w-full max-w-[1077px] bg-white px-4 pt-8 dark:bg-neutral-900 sm:px-6 sm:pt-16">
<h1 className="mb-8 text-2xl font-semibold text-slate-950 dark:text-slate-200 sm:mb-16 sm:text-3xl">
Settings
</h1>
{/* My Account Section */}
<section aria-labelledby="account-heading">
<h2
id="account-heading"
className="mb-8 text-lg font-medium text-neutral-500 dark:text-neutral-400 sm:mb-12"
>
My account
</h2>
<div className="flex max-w-[800px] flex-col gap-7">
{/* Password Input */}
<div className="relative">
<div className="flex flex-col gap-1.5">
<label
htmlFor="password-input"
className="text-base font-medium text-slate-950 dark:text-slate-200"
>
Password
</label>
<input
id="password-input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-[50px] w-full rounded-[35px] border border-neutral-200 bg-transparent px-6 py-3 text-base text-slate-950 dark:border-neutral-700 dark:text-white"
aria-label="Password field"
/>
</div>
</div>
{/* Confirm Password Input */}
<div className="relative">
<div className="flex flex-col gap-1.5">
<label
htmlFor="confirm-password-input"
className="text-base font-medium text-slate-950 dark:text-slate-200"
>
Confirm Password
</label>
<input
id="confirm-password-input"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-[50px] w-full rounded-[35px] border border-neutral-200 bg-transparent px-6 py-3 text-base text-slate-950 dark:border-neutral-700 dark:text-white"
aria-label="Confirm Password field"
/>
</div>
</div>
</div>
</section>
<div
className="my-8 border-t border-neutral-200 dark:border-neutral-700 sm:my-12"
role="separator"
/>
<div className="mt-8 flex justify-end">
<div className="flex gap-3">
<Button
variant="secondary"
className="h-[50px] rounded-[35px] bg-neutral-200 px-6 py-3 font-['Geist'] text-base font-medium text-neutral-800 transition-colors hover:bg-neutral-300 dark:bg-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-600"
onClick={handleCancel}
>
Cancel
</Button>
<Button
variant="default"
className="h-[50px] rounded-[35px] bg-neutral-800 px-6 py-3 font-['Geist'] text-base font-medium text-white transition-colors hover:bg-neutral-900 dark:bg-neutral-900 dark:hover:bg-neutral-800"
onClick={handleSaveChanges}
>
Save changes
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,402 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { User } from "@supabase/supabase-js";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { updateSettings } from "@/app/profile/(user)/settings/actions";
import { toast } from "@/components/ui/use-toast";
import {
NotificationPreference,
NotificationPreferenceDTO,
} from "@/lib/autogpt-server-api";
const formSchema = z
.object({
email: z.string().email(),
password: z
.string()
.optional()
.refine((val) => {
// If password is provided, it must be at least 8 characters
if (val) return val.length >= 8;
return true;
}, "String must contain at least 8 character(s)"),
confirmPassword: z.string().optional(),
notifyOnAgentRun: z.boolean(),
notifyOnZeroBalance: z.boolean(),
notifyOnLowBalance: z.boolean(),
notifyOnBlockExecutionFailed: z.boolean(),
notifyOnContinuousAgentError: z.boolean(),
notifyOnDailySummary: z.boolean(),
notifyOnWeeklySummary: z.boolean(),
notifyOnMonthlySummary: z.boolean(),
})
.refine(
(data) => {
if (data.password || data.confirmPassword) {
return data.password === data.confirmPassword;
}
return true;
},
{
message: "Passwords do not match",
path: ["confirmPassword"],
},
);
interface SettingsFormProps {
user: User;
preferences: NotificationPreferenceDTO;
}
export default function SettingsForm({ user, preferences }: SettingsFormProps) {
const defaultValues = {
email: user.email || "",
password: "",
confirmPassword: "",
notifyOnAgentRun: preferences.preferences.AGENT_RUN,
notifyOnZeroBalance: preferences.preferences.ZERO_BALANCE,
notifyOnLowBalance: preferences.preferences.LOW_BALANCE,
notifyOnBlockExecutionFailed:
preferences.preferences.BLOCK_EXECUTION_FAILED,
notifyOnContinuousAgentError:
preferences.preferences.CONTINUOUS_AGENT_ERROR,
notifyOnDailySummary: preferences.preferences.DAILY_SUMMARY,
notifyOnWeeklySummary: preferences.preferences.WEEKLY_SUMMARY,
notifyOnMonthlySummary: preferences.preferences.MONTHLY_SUMMARY,
};
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues,
});
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
const formData = new FormData();
Object.entries(values).forEach(([key, value]) => {
if (key !== "confirmPassword") {
formData.append(key, value.toString());
}
});
await updateSettings(formData);
toast({
title: "Successfully updated settings",
});
} catch (error) {
toast({
title: "Error",
description:
error instanceof Error ? error.message : "Something went wrong",
variant: "destructive",
});
throw error;
}
}
function onCancel() {
form.reset(defaultValues);
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-8"
>
{/* Account Settings Section */}
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} type="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="************"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm New Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="************"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Notifications Section */}
<div className="flex flex-col gap-6">
<h3 className="text-lg font-medium">Notifications</h3>
{/* Agent Notifications */}
<div className="flex flex-col gap-4">
<h4 className="text-sm font-medium text-muted-foreground">
Agent Notifications
</h4>
<FormField
control={form.control}
name="notifyOnAgentRun"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Agent Run Notifications
</FormLabel>
<FormDescription>
Receive notifications when an agent starts or completes a
run
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notifyOnBlockExecutionFailed"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Block Execution Failures
</FormLabel>
<FormDescription>
Get notified when a block execution fails during agent
runs
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notifyOnContinuousAgentError"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Continuous Agent Errors
</FormLabel>
<FormDescription>
Receive alerts when an agent encounters repeated errors
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Balance Notifications */}
<div className="flex flex-col gap-4">
<h4 className="text-sm font-medium text-muted-foreground">
Balance Notifications
</h4>
<FormField
control={form.control}
name="notifyOnZeroBalance"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Zero Balance Alert
</FormLabel>
<FormDescription>
Get notified when your account balance reaches zero
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notifyOnLowBalance"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Low Balance Warning
</FormLabel>
<FormDescription>
Receive warnings when your balance is running low
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Summary Reports */}
<div className="flex flex-col gap-4">
<h4 className="text-sm font-medium text-muted-foreground">
Summary Reports
</h4>
<FormField
control={form.control}
name="notifyOnDailySummary"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Daily Summary</FormLabel>
<FormDescription>
Receive a daily summary of your account activity
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notifyOnWeeklySummary"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Weekly Summary</FormLabel>
<FormDescription>
Get a weekly overview of your account performance
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notifyOnMonthlySummary"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Monthly Summary</FormLabel>
<FormDescription>
Receive a comprehensive monthly report of your account
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
{/* Form Actions */}
<div className="flex justify-end gap-4">
<Button
variant="outline"
type="button"
onClick={onCancel}
disabled={form.formState.isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={form.formState.isSubmitting || !form.formState.isDirty}
>
{form.formState.isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -33,7 +33,9 @@ import {
StoreReview,
TransactionHistory,
User,
NotificationPreferenceDTO,
UserPasswordCredentials,
NotificationPreference,
RefundRequest,
} from "./types";
import { createBrowserClient } from "@supabase/ssr";
@@ -84,6 +86,10 @@ export default class BackendAPI {
return this._request("POST", "/auth/user", {});
}
updateUserEmail(email: string): Promise<{ email: string }> {
return this._request("POST", "/auth/user/email", { email });
}
getUserCredit(page?: string): Promise<{ credits: number }> {
try {
return this._get(`/credits`, undefined, page);
@@ -92,6 +98,16 @@ export default class BackendAPI {
}
}
getUserPreferences(): Promise<NotificationPreferenceDTO> {
return this._get("/auth/user/preferences");
}
updateUserPreferences(
preferences: NotificationPreferenceDTO,
): Promise<NotificationPreference> {
return this._request("POST", "/auth/user/preferences", preferences);
}
getAutoTopUpConfig(): Promise<{ amount: number; threshold: number }> {
return this._get("/credits/auto-top-up");
}

View File

@@ -366,6 +366,30 @@ export type UserPasswordCredentials = BaseCredentials & {
password: string;
};
// Mirror of backend/backend/data/notifications.py:NotificationType
export type NotificationType =
| "AGENT_RUN"
| "ZERO_BALANCE"
| "LOW_BALANCE"
| "BLOCK_EXECUTION_FAILED"
| "CONTINUOUS_AGENT_ERROR"
| "DAILY_SUMMARY"
| "WEEKLY_SUMMARY"
| "MONTHLY_SUMMARY";
// Mirror of backend/backend/data/notifications.py:NotificationPreference
export type NotificationPreferenceDTO = {
email: string;
preferences: { [key in NotificationType]: boolean };
daily_limit: number;
};
export type NotificationPreference = NotificationPreferenceDTO & {
user_id: string;
emails_sent_today: number;
last_reset_date: Date;
};
/* Mirror of backend/data/integrations.py:Webhook */
export type Webhook = {
id: string;