Add user profile customization with name, role, email, and phone

- Add UserProfileSchema to ClientSettings with name, role, email, phone fields
- Update database layer to persist profile data in client_settings table
- Rewrite ProfileOverlay component as editable form with save/cancel actions
- Update Topbar to display user's initials from profile name
- Profile data loaded on app mount and saved via settings.update RPC

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 00:26:57 -04:00
parent 0624026af3
commit c8a39e6416
4 changed files with 126 additions and 6 deletions

View File

@@ -14,6 +14,7 @@ import type {
RpcResult, RpcResult,
ServerSettings, ServerSettings,
Screen, Screen,
UserProfile,
WorkspaceSection, WorkspaceSection,
} from "@mosaiciq/contracts/rpc"; } from "@mosaiciq/contracts/rpc";
import { rpc } from "../../rpcClient"; import { rpc } from "../../rpcClient";
@@ -140,6 +141,7 @@ export function App() {
const [llmLoading, setLlmLoading] = useState(false); const [llmLoading, setLlmLoading] = useState(false);
const [selectedLlmModel, setSelectedLlmModel] = useState<string>(""); const [selectedLlmModel, setSelectedLlmModel] = useState<string>("");
const [keybindingsDraft, setKeybindingsDraft] = useState<Record<string, string>>({}); const [keybindingsDraft, setKeybindingsDraft] = useState<Record<string, string>>({});
const [profile, setProfile] = useState<Partial<UserProfile>>({});
const [workspaceSection, setWorkspaceSection] = useState<WorkspaceSection | null>(null); const [workspaceSection, setWorkspaceSection] = useState<WorkspaceSection | null>(null);
const [workspaceSources, setWorkspaceSources] = useState<WorkspaceSource[]>([]); const [workspaceSources, setWorkspaceSources] = useState<WorkspaceSource[]>([]);
const [workspaceLoading, setWorkspaceLoading] = useState(false); const [workspaceLoading, setWorkspaceLoading] = useState(false);
@@ -294,6 +296,7 @@ export function App() {
if (settings.theme) setTheme(settings.theme); if (settings.theme) setTheme(settings.theme);
if (settings.density) setDensity(settings.density); if (settings.density) setDensity(settings.density);
setKeybindingsDraft(settings.keybindings ?? {}); setKeybindingsDraft(settings.keybindings ?? {});
setProfile(settings.profile ?? {});
} }
const server = await rpc.call("settings.get", { scope: "server" }); const server = await rpc.call("settings.get", { scope: "server" });
if (server.ok) { if (server.ok) {
@@ -557,6 +560,7 @@ export function App() {
onScreenChange={setActiveScreen} onScreenChange={setActiveScreen}
onSettingsOpen={() => setSettingsOpen(true)} onSettingsOpen={() => setSettingsOpen(true)}
searchOpen={searchOpen} searchOpen={searchOpen}
profile={profile}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={(q) => { onSearchChange={(q) => {
setSearchOpen(true); setSearchOpen(true);
@@ -876,6 +880,12 @@ export function App() {
<ProfileOverlay <ProfileOverlay
open={profileOpen} open={profileOpen}
onClose={() => setProfileOpen(false)} onClose={() => setProfileOpen(false)}
profile={profile}
onSave={async (newProfile) => {
setProfile(newProfile);
const result = await rpc.call("settings.update", { scope: "client", changes: { profile: newProfile } });
if (!result.ok) addToast({ type: "error", title: "Profile save failed", desc: result.error.message });
}}
/> />
<AgentFullscreenOverlay <AgentFullscreenOverlay
open={agentFullscreenOpen} open={agentFullscreenOpen}
@@ -1034,6 +1044,7 @@ function Topbar(props: {
ticker?: string; ticker?: string;
action?: string; action?: string;
}>; }>;
profile?: Partial<UserProfile>;
}) { }) {
return ( return (
<header className={ui.topbar}> <header className={ui.topbar}>
@@ -1120,8 +1131,16 @@ function Topbar(props: {
<button <button
className="grid h-7 w-7 place-items-center rounded-full border border-[var(--border)] bg-[var(--surface)] font-[var(--font-mono)] text-[11px] font-semibold text-[var(--muted)] hover:border-[var(--accent)] hover:text-[var(--accent)]" className="grid h-7 w-7 place-items-center rounded-full border border-[var(--border)] bg-[var(--surface)] font-[var(--font-mono)] text-[11px] font-semibold text-[var(--muted)] hover:border-[var(--accent)] hover:text-[var(--accent)]"
onClick={props.onProfileOpen} onClick={props.onProfileOpen}
title={props.profile?.name ? `${props.profile.name}'s profile` : "Profile"}
> >
JD {props.profile?.name
? props.profile.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
: "JD"}
</button> </button>
</header> </header>
); );
@@ -3301,11 +3320,34 @@ function SettingsOverlay({
function ProfileOverlay({ function ProfileOverlay({
open, open,
onClose, onClose,
profile,
onSave,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
profile: Partial<UserProfile>;
onSave: (profile: Partial<UserProfile>) => Promise<void>;
}) { }) {
const [draft, setDraft] = useState<Partial<UserProfile>>(profile);
const [saving, setSaving] = useState(false);
useEffect(() => {
setDraft(profile);
}, [profile]);
if (!open) return null; if (!open) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await onSave(draft);
onClose();
} finally {
setSaving(false);
}
};
return ( return (
<div <div
className={cx(ui.overlayWide, "overlay-backdrop open")} className={cx(ui.overlayWide, "overlay-backdrop open")}
@@ -3318,15 +3360,76 @@ function ProfileOverlay({
<section className="overlay-body w-[420px] border border-[var(--border)] bg-[var(--surface)] p-5 shadow-2xl"> <section className="overlay-body w-[420px] border border-[var(--border)] bg-[var(--surface)] p-5 shadow-2xl">
<div className="mb-5 flex items-center justify-between"> <div className="mb-5 flex items-center justify-between">
<h2 className="font-[var(--font-display)] text-2xl font-semibold"> <h2 className="font-[var(--font-display)] text-2xl font-semibold">
JD Profile Profile
</h2> </h2>
<button className={ui.iconBtn} onClick={onClose}> <button className={ui.iconBtn} onClick={onClose}>
× ×
</button> </button>
</div> </div>
<SettingsRow label="Name" value="Jordan Davis" /> <form onSubmit={handleSubmit}>
<SettingsRow label="Role" value="Portfolio Manager" /> <SettingsRow
<p className="mt-4 text-xs text-[var(--muted)]">Profile editing is local-only until a profile backend is added.</p> label="Name"
value={draft.name}
>
<input
className="w-full border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm outline-none focus:border-[var(--accent)]"
value={draft.name ?? ""}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="Your name"
/>
</SettingsRow>
<SettingsRow
label="Role"
value={draft.role}
>
<input
className="w-full border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm outline-none focus:border-[var(--accent)]"
value={draft.role ?? ""}
onChange={(e) => setDraft({ ...draft, role: e.target.value })}
placeholder="Portfolio Manager, Analyst, etc."
/>
</SettingsRow>
<SettingsRow
label="Email"
value={draft.email}
>
<input
type="email"
className="w-full border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm outline-none focus:border-[var(--accent)]"
value={draft.email ?? ""}
onChange={(e) => setDraft({ ...draft, email: e.target.value || undefined })}
placeholder="you@example.com"
/>
</SettingsRow>
<SettingsRow
label="Phone"
value={draft.phone}
>
<input
type="tel"
className="w-full border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm outline-none focus:border-[var(--accent)]"
value={draft.phone ?? ""}
onChange={(e) => setDraft({ ...draft, phone: e.target.value || undefined })}
placeholder="+1 (555) 123-4567"
/>
</SettingsRow>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="rounded bg-[var(--accent)] px-4 py-2 text-sm font-medium text-[var(--accent-fg)] hover:opacity-90 disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</form>
</section> </section>
</div> </div>
); );

View File

@@ -187,6 +187,8 @@ export type ClientSettings = z.infer<typeof import("./rpcSchemas.js").ClientSett
export type ServerSettings = z.infer<typeof import("./rpcSchemas.js").ServerSettingsSchema>; export type ServerSettings = z.infer<typeof import("./rpcSchemas.js").ServerSettingsSchema>;
export type UserProfile = z.infer<typeof import("./rpcSchemas.js").UserProfileSchema>;
export type RpcRequestMap = { export type RpcRequestMap = {
"portfolio.get": undefined; "portfolio.get": undefined;
"portfolio.addHolding": { ticker: string }; "portfolio.addHolding": { ticker: string };

View File

@@ -28,12 +28,20 @@ const tickerString = z.string().trim().min(1).max(16);
const nonNegativeIndex = z.number().int().min(0); const nonNegativeIndex = z.number().int().min(0);
const unknownRecord = z.record(z.unknown()); const unknownRecord = z.record(z.unknown());
export const UserProfileSchema = z.object({
name: z.string().min(1).default(""),
role: z.string().min(1).default(""),
email: z.string().email().optional(),
phone: z.string().optional(),
});
export const ClientSettingsSchema = z.object({ export const ClientSettingsSchema = z.object({
theme: z.enum(["light", "dark", "system"]), theme: z.enum(["light", "dark", "system"]),
density: z.enum(["comfortable", "compact", "dense"]), density: z.enum(["comfortable", "compact", "dense"]),
sidebarWidth: z.number().int().min(160).max(520), sidebarWidth: z.number().int().min(160).max(520),
navCollapsed: z.record(z.boolean()), navCollapsed: z.record(z.boolean()),
keybindings: z.record(z.string()), keybindings: z.record(z.string()),
profile: UserProfileSchema.partial().default({}),
}); });
export const ServerSettingsSchema = z.object({ export const ServerSettingsSchema = z.object({

View File

@@ -25,7 +25,7 @@ import type {
ClientSettings, ClientSettings,
ServerSettings, ServerSettings,
} from "@mosaiciq/contracts/rpc"; } from "@mosaiciq/contracts/rpc";
import { ClientSettingsSchema, ServerSettingsSchema } from "@mosaiciq/contracts/rpcSchemas"; import { ClientSettingsSchema, ServerSettingsSchema, UserProfileSchema } from "@mosaiciq/contracts/rpcSchemas";
export function parseJsonWithSchema<T>( export function parseJsonWithSchema<T>(
value: string, value: string,
@@ -982,6 +982,7 @@ const DEFAULT_CLIENT_SETTINGS: ClientSettings = {
"navigation.agents": "Cmd+4", "navigation.agents": "Cmd+4",
"navigation.home": "Cmd+0", "navigation.home": "Cmd+0",
}, },
profile: {},
}; };
export function getClientSettings(db: Db): ClientSettings { export function getClientSettings(db: Db): ClientSettings {
@@ -999,6 +1000,9 @@ export function getClientSettings(db: Db): ClientSettings {
} else if (row.key === "navCollapsed") { } else if (row.key === "navCollapsed") {
const navCollapsed = z.record(z.boolean()).safeParse(parsed); const navCollapsed = z.record(z.boolean()).safeParse(parsed);
if (navCollapsed.success) settings.navCollapsed = { ...settings.navCollapsed, ...navCollapsed.data }; if (navCollapsed.success) settings.navCollapsed = { ...settings.navCollapsed, ...navCollapsed.data };
} else if (row.key === "profile") {
const profile = UserProfileSchema.partial().safeParse(parsed);
if (profile.success) settings.profile = { ...settings.profile, ...profile.data };
} else { } else {
const candidate = { ...settings, [row.key]: parsed }; const candidate = { ...settings, [row.key]: parsed };
const validated = ClientSettingsSchema.safeParse(candidate); const validated = ClientSettingsSchema.safeParse(candidate);
@@ -1041,6 +1045,9 @@ export function updateClientSettings(db: Db, settings: Partial<ClientSettings>):
if (settings.keybindings !== undefined) { if (settings.keybindings !== undefined) {
updateClientSetting(db, "keybindings", settings.keybindings); updateClientSetting(db, "keybindings", settings.keybindings);
} }
if (settings.profile !== undefined) {
updateClientSetting(db, "profile", settings.profile);
}
} }
export function getServerSettings(db: Db): ServerSettings { export function getServerSettings(db: Db): ServerSettings {