user settings

This commit is contained in:
Rob Colbert 2026-05-08 14:08:49 -04:00
parent 9e9bc5267a
commit 8eff66dcec
6 changed files with 360 additions and 19 deletions

View File

@ -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() {
<Route path="/projects/new" element={<ProjectManager user={user} />} />
<Route path="/projects/:slug" element={<ProjectManager user={user} />} />
<Route path="/drones" element={<DroneManager user={user} />} />
<Route path="/settings" element={user ? <Settings /> : <Navigate to="/sign-in" replace />} />
<Route path="/projects/:projectId/chat-session/:sessionId" element={<ChatSessionView />} />
<Route
path="/sign-in"

View File

@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import type { User } from '../lib/api';
const APP_VERSION = '1.0.0';
@ -10,6 +10,7 @@ interface HeaderProps {
}
export default function Header({ user, onSignOut }: HeaderProps) {
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@ -54,6 +55,10 @@ export default function Header({ user, onSignOut }: HeaderProps) {
>
<div className="py-1">
<button
onClick={() => {
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

View File

@ -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<User>("/api/v1/user/settings", data),
};
export const projectApi = {
getAll: () => api.get<Project[]>("/api/v1/projects"),
get: (id: string) => api.get<Project>(`/api/v1/projects/${id}`),

View File

@ -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<string, string | undefined> = {
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 (
<div className="flex-1 flex bg-bg-primary overflow-hidden">
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-xl mx-auto">
<div className="border-2 border-border-default p-6 rounded bg-bg-secondary">
<div className="font-mono text-text-secondary text-sm">
<div className="mb-1 text-text-muted">// USER SETTINGS</div>
<div className="mb-6 text-text-primary">ACCOUNT CONFIGURATION</div>
<div className="mb-6 p-3 border border-border-subtle rounded bg-bg-tertiary">
<div className="text-text-muted text-xs">
Signed in as <span className="text-text-primary">{user?.email}</span>
</div>
<div className="text-text-muted text-xs mt-1">
Email address cannot be changed. Contact your administrator.
</div>
</div>
{error && (
<div className="mb-4 p-3 border border-red-800 rounded bg-red-950/50">
<div className="text-red-400 font-mono text-sm">
<span className="text-text-muted">// ERROR: </span>
{error}
</div>
</div>
)}
{success && (
<div className="mb-4 p-3 border border-green-800 rounded bg-green-950/50">
<div className="text-green-400 font-mono text-sm">
<span className="text-text-muted">// SUCCESS: </span>
{success}
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="displayName"
className="block text-sm text-text-muted mb-1 font-mono"
>
display_name
</label>
<input
id="displayName"
type="text"
value={displayName}
onChange={(e) => 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
/>
</div>
<div>
<div className="text-xs text-text-muted mb-3 font-mono tracking-wider">
--- PASSWORD ---
</div>
<div className="space-y-3">
<div>
<label
htmlFor="currentPassword"
className="block text-sm text-text-muted mb-1 font-mono"
>
current_password
</label>
<input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => 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"
/>
</div>
<div>
<label
htmlFor="newPassword"
className="block text-sm text-text-muted mb-1 font-mono"
>
new_password
</label>
<input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => 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"
/>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm text-text-muted mb-1 font-mono"
>
confirm_new_password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => 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"
/>
</div>
</div>
</div>
<div>
<div className="text-xs text-text-muted mb-3 font-mono tracking-wider">
--- PERSONA ---
</div>
<label
htmlFor="persona"
className="block text-sm text-text-muted mb-1 font-mono"
>
Describe yourself for your Agent
</label>
<textarea
id="persona"
value={persona}
onChange={(e) => 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..."
/>
<div
className={`text-right text-xs mt-1 font-mono ${
personaRemaining < 50
? "text-red-400"
: "text-text-muted"
}`}
>
{personaRemaining}/{PERSONA_MAX} characters remaining
</div>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => 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
</button>
<button
type="submit"
disabled={saving}
className="flex-1 px-4 py-2 bg-brand hover:bg-red-700 text-white rounded transition-colors disabled:opacity-50 font-mono text-sm"
>
{saving ? "SAVING..." : "SAVE SETTINGS"}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -2,9 +2,10 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// 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<void> {
@ -35,6 +37,25 @@ export class UserApiControllerV1 extends DtpController {
},
});
}
async putUpdateSettings(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
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;

View File

@ -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<IUser> {
const update: MongooseBaseQueryOptions = { $set: {} };
let needsEmailVerification = false;
const update: Record<string, unknown> = { $set: {} };
const $set = update.$set as Record<string, unknown>;
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;
}