user settings
This commit is contained in:
parent
9e9bc5267a
commit
8eff66dcec
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}`),
|
||||
|
||||
256
gadget-code/frontend/src/pages/Settings.tsx
Normal file
256
gadget-code/frontend/src/pages/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user