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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user