diff --git a/gadget-code/frontend/src/App.tsx b/gadget-code/frontend/src/App.tsx index 0c361c1..6c7be43 100644 --- a/gadget-code/frontend/src/App.tsx +++ b/gadget-code/frontend/src/App.tsx @@ -9,6 +9,7 @@ import ProjectManager from './pages/ProjectManager'; import SignIn from './pages/SignIn'; import ChatSessionView from './pages/ChatSessionView'; import DroneManager from './pages/DroneManager'; +import Settings from './pages/Settings'; const TOKEN_KEY = 'dtp_auth_token'; const USER_KEY = 'dtp_user'; @@ -122,6 +123,7 @@ export default function App() { } /> } /> } /> + : } /> } /> (null); @@ -54,6 +55,10 @@ export default function Header({ user, onSignOut }: HeaderProps) { > { + setMenuOpen(false); + navigate('/settings'); + }} className="w-full px-4 py-2 text-left text-sm text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors" > Settings diff --git a/gadget-code/frontend/src/lib/api.ts b/gadget-code/frontend/src/lib/api.ts index af34d2a..fb3ef5e 100644 --- a/gadget-code/frontend/src/lib/api.ts +++ b/gadget-code/frontend/src/lib/api.ts @@ -161,6 +161,7 @@ export interface User { email: string; displayName: string; flags: string[]; + persona?: string; } export interface AuthResponse { @@ -178,6 +179,15 @@ export interface Project { gitUrl?: string; } +export const userApi = { + updateSettings: (data: { + displayName?: string; + currentPassword?: string; + password?: string; + persona?: string; + }) => api.put("/api/v1/user/settings", data), +}; + export const projectApi = { getAll: () => api.get("/api/v1/projects"), get: (id: string) => api.get(`/api/v1/projects/${id}`), diff --git a/gadget-code/frontend/src/pages/Settings.tsx b/gadget-code/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..110079b --- /dev/null +++ b/gadget-code/frontend/src/pages/Settings.tsx @@ -0,0 +1,256 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { userApi } from "../lib/api"; +import { useAppContext } from "../App"; + +const PERSONA_MAX = 500; + +export default function Settings() { + const { user, setStatusMessage } = useAppContext(); + const navigate = useNavigate(); + + const [displayName, setDisplayName] = useState(user?.displayName ?? ""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [persona, setPersona] = useState(user?.persona ?? ""); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [saving, setSaving] = useState(false); + + const personaRemaining = PERSONA_MAX - persona.length; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setSuccess(""); + + if (displayName.length < 3 || displayName.length > 30) { + setError("Display Name must be between 3-30 characters."); + return; + } + + const changingPassword = + currentPassword || newPassword || confirmPassword; + + if (changingPassword) { + if (!currentPassword) { + setError("Current password is required to change your password."); + return; + } + if (!newPassword) { + setError("New password is required."); + return; + } + if (newPassword.length < 8) { + setError("New password must be at least 8 characters."); + return; + } + if (newPassword !== confirmPassword) { + setError("New passwords do not match."); + return; + } + } + + if (persona.length > PERSONA_MAX) { + setError(`Persona must be ${PERSONA_MAX} characters or fewer.`); + return; + } + + setSaving(true); + try { + const body: Record = { + displayName, + persona, + }; + if (changingPassword) { + body.currentPassword = currentPassword; + body.password = newPassword; + } + + const updatedUser = await userApi.updateSettings(body); + setSuccess("Settings saved successfully."); + setStatusMessage("Settings updated."); + + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + + const storedUser = localStorage.getItem("dtp_user"); + if (storedUser) { + const parsed = JSON.parse(storedUser); + Object.assign(parsed, updatedUser); + localStorage.setItem("dtp_user", JSON.stringify(parsed)); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save settings."); + } finally { + setSaving(false); + } + }; + + return ( + + + + + + // USER SETTINGS + ACCOUNT CONFIGURATION + + + + Signed in as {user?.email} + + + Email address cannot be changed. Contact your administrator. + + + + {error && ( + + + // ERROR: + {error} + + + )} + + {success && ( + + + // SUCCESS: + {success} + + + )} + + + + + display_name + + setDisplayName(e.target.value)} + className="w-full px-4 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary font-mono focus:outline-none focus:border-border-highlight" + minLength={3} + maxLength={30} + required + /> + + + + + --- PASSWORD --- + + + + + current_password + + setCurrentPassword(e.target.value)} + className="w-full px-4 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary font-mono focus:outline-none focus:border-border-highlight" + autoComplete="current-password" + /> + + + + new_password + + setNewPassword(e.target.value)} + className="w-full px-4 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary font-mono focus:outline-none focus:border-border-highlight" + autoComplete="new-password" + /> + + + + confirm_new_password + + setConfirmPassword(e.target.value)} + className="w-full px-4 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary font-mono focus:outline-none focus:border-border-highlight" + autoComplete="new-password" + /> + + + + + + + --- PERSONA --- + + + Describe yourself for your Agent + + setPersona(e.target.value)} + className="w-full px-4 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary font-mono focus:outline-none focus:border-border-highlight resize-y min-h-[120px]" + maxLength={PERSONA_MAX} + placeholder="e.g. I'm a frontend engineer focused on React. I need extra help with accessibility and testing..." + /> + + {personaRemaining}/{PERSONA_MAX} characters remaining + + + + + navigate(-1)} + className="flex-1 px-4 py-2 text-center border border-border-highlight text-text-primary hover:bg-bg-tertiary rounded transition-colors font-mono text-sm" + > + CANCEL + + + {saving ? "SAVING..." : "SAVE SETTINGS"} + + + + + + + + + ); +} diff --git a/gadget-code/src/controllers/api/v1/user.ts b/gadget-code/src/controllers/api/v1/user.ts index 523fd00..49292b2 100644 --- a/gadget-code/src/controllers/api/v1/user.ts +++ b/gadget-code/src/controllers/api/v1/user.ts @@ -2,9 +2,10 @@ // Copyright (C) 2026 Robert Colbert // All Rights Reserved -import { Request, Response } from "express"; +import { NextFunction, Request, Response } from "express"; import { DtpController } from "../../../lib/controller.js"; +import UserService from "../../../services/user.js"; export class UserApiControllerV1 extends DtpController { get name(): string { @@ -25,6 +26,7 @@ export class UserApiControllerV1 extends DtpController { this.router.use(this.requireUser()); this.router.get("/", this.getUser.bind(this)); + this.router.put("/settings", this.putUpdateSettings.bind(this)); } async getUser(req: Request, res: Response): Promise { @@ -35,6 +37,25 @@ export class UserApiControllerV1 extends DtpController { }, }); } + + async putUpdateSettings( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const updatedUser = await UserService.updateForUser(req.user!, req.body); + res.status(200).json({ + success: true, + data: { + user: updatedUser, + }, + }); + } catch (error) { + this.log.error("failed to update user settings", { error }); + next(error); + } + } } export default UserApiControllerV1; diff --git a/gadget-code/src/services/user.ts b/gadget-code/src/services/user.ts index 4e0a6cc..7daf487 100644 --- a/gadget-code/src/services/user.ts +++ b/gadget-code/src/services/user.ts @@ -18,9 +18,10 @@ import { DtpService } from "../lib/service.js"; import { DtpPaginationParameters } from "../lib/pagination-parameters.ts"; export interface IUserUpdate { - email: string; displayName?: string; + currentPassword?: string; password?: string; + persona?: string; } export interface IUserAdminUpdate { @@ -127,29 +128,79 @@ class UserService extends DtpService { } async updateForUser(user: IUser, def: IUserUpdate): Promise { - const update: MongooseBaseQueryOptions = { $set: {} }; - let needsEmailVerification = false; + const update: Record = { $set: {} }; + const $set = update.$set as Record; - if (def.email && def.email !== user.email) { - update.$set.email = filterText(def.email); - update.$set.email_lc = update.$set.email.toLowerCase(); - update.$set["flags.isEmailVerified"] = false; - needsEmailVerification = true; - this.log.info("updating user email address", { + if (def.displayName !== undefined) { + const displayName = filterText(def.displayName); + if (displayName.length < 3 || displayName.length > 30) { + const err = new Error( + "Display Name must be between 3-30 characters in length.", + ); + err.statusCode = 400; + throw err; + } + $set.displayName = displayName; + this.log.info("updating user display name", { + user: { _id: user._id }, + displayName, + }); + } + + if (def.persona !== undefined) { + if (def.persona.length > 500) { + const err = new Error( + "Persona must be 500 characters or fewer.", + ); + err.statusCode = 400; + throw err; + } + $set.persona = def.persona; + this.log.info("updating user persona", { user: { _id: user._id }, - email: update.$set.email, }); } if (def.password) { + if (!def.currentPassword) { + const err = new Error( + "Current password is required to set a new password.", + ); + err.statusCode = 400; + throw err; + } + + const existingUser = await User.findOne({ _id: user._id }).select( + "+passwordSalt +password", + ); + if (!existingUser) { + const error = new Error(`User ${user._id} not found`); + error.statusCode = 400; + throw error; + } + + const maskedCurrent = await CryptoService.maskPassword( + existingUser.passwordSalt!, + def.currentPassword.trim(), + ); + if (maskedCurrent !== existingUser.password) { + const err = new Error("Current password is incorrect."); + err.statusCode = 400; + throw err; + } + this.log.info("updating user password", { user: { _id: user._id } }); - update.$set.passwordSalt = uuidv4(); - update.$set.password = await CryptoService.maskPassword( - update.$set.passwordSalt, + $set.passwordSalt = uuidv4(); + $set.password = await CryptoService.maskPassword( + $set.passwordSalt as string, def.password.trim(), ); } + if (Object.keys($set).length === 0) { + return user; + } + const newUser = await User.findOneAndUpdate({ _id: user._id }, update, { new: true, }); @@ -159,10 +210,6 @@ class UserService extends DtpService { throw error; } - if (needsEmailVerification) { - await ContactService.sendVerificationEmail(newUser); - } - return newUser; }