mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ###########################
|
||||
########################################################
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
15
autogpt_platform/frontend/src/components/ui/skeleton.tsx
Normal file
15
autogpt_platform/frontend/src/components/ui/skeleton.tsx
Normal 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 };
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user