From fe99469e9b12f7d113f01b1549752028cc44e781 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 25 Jan 2026 15:20:48 -0800 Subject: [PATCH] feat(desktop): rework account settings with editable profile - Add updateProfile and uploadAvatar mutations to user router - Remove version section from account settings - Add editable name field with onBlur save - Add avatar upload with hover edit overlay - Use Electric SQL collections for real-time data sync --- .../AccountSettings/AccountSettings.tsx | 207 ++++++++++++------ .../ProfileSkeleton/ProfileSkeleton.tsx | 28 +++ .../components/ProfileSkeleton/index.ts | 1 + .../utils/settings-search/settings-search.ts | 17 -- packages/trpc/src/router/user/user.ts | 98 ++++++++- 5 files changed, 261 insertions(+), 90 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/components/ProfileSkeleton/ProfileSkeleton.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/components/ProfileSkeleton/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/AccountSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/AccountSettings.tsx index ed0e486b089..a2e4f5d40f3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/AccountSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/AccountSettings.tsx @@ -1,15 +1,21 @@ import { Avatar } from "@superset/ui/atoms/Avatar"; import { Button } from "@superset/ui/button"; -import { Skeleton } from "@superset/ui/skeleton"; +import { Card, CardContent } from "@superset/ui/card"; +import { Input } from "@superset/ui/input"; import { toast } from "@superset/ui/sonner"; -import { LuCopy } from "react-icons/lu"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useEffect, useState } from "react"; +import { HiOutlinePencil } from "react-icons/hi2"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { isItemVisible, SETTING_ITEM_ID, type SettingItemId, } from "../../../utils/settings-search"; +import { ProfileSkeleton } from "./components/ProfileSkeleton"; interface AccountSettingsProps { visibleItems?: SettingItemId[] | null; @@ -20,22 +26,77 @@ export function AccountSettings({ visibleItems }: AccountSettingsProps) { SETTING_ITEM_ID.ACCOUNT_PROFILE, visibleItems, ); - const showVersion = isItemVisible( - SETTING_ITEM_ID.ACCOUNT_VERSION, - visibleItems, - ); const showSignOut = isItemVisible( SETTING_ITEM_ID.ACCOUNT_SIGNOUT, visibleItems, ); - const { data: session, isPending: isLoading } = authClient.useSession(); - const user = session?.user; + const { data: session } = authClient.useSession(); + const currentUserId = session?.user?.id; + const collections = useCollections(); + + const [nameValue, setNameValue] = useState(""); + const [avatarPreview, setAvatarPreview] = useState(null); + + const { data: usersData, isLoading } = useLiveQuery( + (q) => q.from({ users: collections.users }), + [collections], + ); + + const user = usersData?.find((u) => u.id === currentUserId); + const signOutMutation = electronTrpc.auth.signOut.useMutation({ onSuccess: () => toast.success("Signed out"), }); - const signOut = () => signOutMutation.mutate(); + const selectImageMutation = electronTrpc.window.selectImageFile.useMutation(); + + useEffect(() => { + if (!user) return; + setNameValue(user.name ?? ""); + setAvatarPreview(user.image ?? null); + }, [user]); + + async function handleAvatarUpload() { + if (!user) return; + + try { + const result = await selectImageMutation.mutateAsync(); + if (result.canceled || !result.dataUrl) return; + + const mimeMatch = result.dataUrl.match(/^data:([^;]+);/); + const mimeType = mimeMatch?.[1] || "image/png"; + const ext = mimeType.split("/")[1] || "png"; + + const uploadResult = await apiTrpcClient.user.uploadAvatar.mutate({ + fileData: result.dataUrl, + fileName: `avatar.${ext}`, + mimeType, + }); + + setAvatarPreview(uploadResult.url); + toast.success("Avatar updated!"); + } catch { + toast.error("Failed to update avatar"); + } + } + + async function handleNameBlur() { + if (!user || nameValue === user.name) return; + + if (!nameValue) { + setNameValue(user.name ?? ""); + return; + } + + try { + await apiTrpcClient.user.updateProfile.mutate({ name: nameValue }); + toast.success("Name updated!"); + } catch { + toast.error("Failed to update name"); + setNameValue(user.name ?? ""); + } + } return (
@@ -47,80 +108,84 @@ export function AccountSettings({ visibleItems }: AccountSettingsProps) {
- {/* Profile Section */} {showProfile && (

Profile

-
- {isLoading ? ( - <> - -
- - -
- - ) : user ? ( - <> - -
-

{user.name}

-

- {user.email} -

-
- - ) : ( -

- Unable to load user info -

- )} -
-
- )} + {isLoading ? ( + + ) : user ? ( + + +
    +
  • +
    +
    Avatar
    +
    + Recommended size is 256x256px +
    +
    + +
  • - {/* Version Section */} - {showVersion && ( -
    -

    Version

    -
    -

    - Superset Desktop v{window.App?.appVersion ?? "unknown"} -

    - -
    +
  • +
    Name
    +
    + setNameValue(e.target.value)} + onBlur={handleNameBlur} + placeholder="Your name" + className="w-full" + /> +
    +
  • + +
  • +
    Email
    +
    + +
    +
  • +
+
+
+ ) : ( + + +

+ Unable to load user info +

+
+
+ )}
)} - {/* Sign Out Section */} {showSignOut && ( -
+

Sign Out

Sign out of your Superset account on this device.

-
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/components/ProfileSkeleton/ProfileSkeleton.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/components/ProfileSkeleton/ProfileSkeleton.tsx new file mode 100644 index 00000000000..af97420f60f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/components/ProfileSkeleton/ProfileSkeleton.tsx @@ -0,0 +1,28 @@ +import { Card, CardContent } from "@superset/ui/card"; +import { Skeleton } from "@superset/ui/skeleton"; + +export function ProfileSkeleton() { + return ( + + +
    +
  • +
    + + +
    + +
  • +
  • + + +
  • +
  • + + +
  • +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/components/ProfileSkeleton/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/components/ProfileSkeleton/index.ts new file mode 100644 index 00000000000..547b83912f1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/components/ProfileSkeleton/index.ts @@ -0,0 +1 @@ +export { ProfileSkeleton } from "./ProfileSkeleton"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index 99e6fde6943..71295837290 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -2,7 +2,6 @@ import type { SettingsSection } from "renderer/stores/settings-state"; export const SETTING_ITEM_ID = { ACCOUNT_PROFILE: "account-profile", - ACCOUNT_VERSION: "account-version", ACCOUNT_SIGNOUT: "account-signout", ORGANIZATION_LOGO: "organization-logo", @@ -78,22 +77,6 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "me", ], }, - { - id: SETTING_ITEM_ID.ACCOUNT_VERSION, - section: "account", - title: "Version", - description: "App version and updates", - keywords: [ - "account", - "version", - "update", - "check for updates", - "app version", - "release", - "about", - "upgrade", - ], - }, { id: SETTING_ITEM_ID.ACCOUNT_SIGNOUT, section: "account", diff --git a/packages/trpc/src/router/user/user.ts b/packages/trpc/src/router/user/user.ts index 53aaaef109a..3c108db237b 100644 --- a/packages/trpc/src/router/user/user.ts +++ b/packages/trpc/src/router/user/user.ts @@ -1,7 +1,9 @@ import { db } from "@superset/db/client"; -import { members } from "@superset/db/schema"; -import type { TRPCRouterRecord } from "@trpc/server"; +import { members, users } from "@superset/db/schema"; +import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; +import { del, put } from "@vercel/blob"; import { eq } from "drizzle-orm"; +import { z } from "zod"; import { protectedProcedure } from "../../trpc"; @@ -29,4 +31,96 @@ export const userRouter = { return memberships.map((m) => m.organization); }), + + updateProfile: protectedProcedure + .input(z.object({ name: z.string().min(1).max(100) })) + .mutation(async ({ ctx, input }) => { + const [updatedUser] = await db + .update(users) + .set({ name: input.name }) + .where(eq(users.id, ctx.session.user.id)) + .returning(); + return updatedUser; + }), + + uploadAvatar: protectedProcedure + .input( + z.object({ + fileData: z.string(), + fileName: z.string(), + mimeType: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + const allowedMimeTypes = ["image/png", "image/jpeg", "image/webp"]; + if (!allowedMimeTypes.includes(input.mimeType)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid image type. Only PNG, JPEG, and WebP are allowed", + }); + } + + const base64Data = input.fileData.includes("base64,") + ? input.fileData.split("base64,")[1] || input.fileData + : input.fileData; + const buffer = Buffer.from(base64Data, "base64"); + + const sizeInMB = buffer.length / (1024 * 1024); + if (sizeInMB > 4.5) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `File too large (${sizeInMB.toFixed(2)}MB). Maximum size is 4.5MB`, + }); + } + + if (user.image) { + try { + await del(user.image); + } catch { + // Old avatar doesn't exist or isn't in blob storage - that's fine + } + } + + const ext = input.mimeType.split("/")[1]?.replace("jpeg", "jpg") || "png"; + const randomId = Math.random().toString(36).substring(2, 15); + const pathname = `user/${userId}/avatar/${randomId}.${ext}`; + + try { + const blob = await put(pathname, buffer, { + access: "public", + contentType: input.mimeType, + }); + + const [updatedUser] = await db + .update(users) + .set({ image: blob.url }) + .where(eq(users.id, userId)) + .returning(); + + return { + success: true, + url: blob.url, + user: updatedUser, + }; + } catch (error) { + console.error("[user/uploadAvatar] Upload failed:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to upload avatar", + }); + } + }), } satisfies TRPCRouterRecord;