aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-12-24 12:18:08 +0200
committerGitHub <noreply@github.com>2025-12-24 10:18:08 +0000
commit314c363e5ca69a50626650ade8968feec583e5ce (patch)
tree2251691c2a79598b50b4417ee5632b602e5faf78
parent3408e6e4854dc79b963eef455e9a69231de3cd28 (diff)
downloadkarakeep-314c363e5ca69a50626650ade8968feec583e5ce.tar.zst
feat: add support for user avatars (#2296)
* feat: add support for user avatars * more fixes * more fixes * more fixes * more fixes
-rw-r--r--apps/web/app/settings/info/page.tsx2
-rw-r--r--apps/web/components/dashboard/header/ProfileOptions.tsx23
-rw-r--r--apps/web/components/settings/UserAvatar.tsx149
-rw-r--r--apps/web/components/ui/avatar.tsx49
-rw-r--r--apps/web/components/ui/user-avatar.tsx52
-rw-r--r--apps/web/lib/attachments.tsx2
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json11
-rw-r--r--apps/web/lib/i18n/locales/en_US/translation.json11
-rw-r--r--apps/web/package.json1
-rw-r--r--apps/web/server/auth.ts1
-rw-r--r--packages/db/schema.ts2
-rw-r--r--packages/open-api/karakeep-openapi-spec.json7
-rw-r--r--packages/shared-react/hooks/users.ts13
-rw-r--r--packages/shared/types/bookmarks.ts1
-rw-r--r--packages/shared/types/users.ts1
-rw-r--r--packages/trpc/lib/attachments.ts5
-rw-r--r--packages/trpc/models/users.ts102
-rw-r--r--packages/trpc/routers/users.test.ts75
-rw-r--r--packages/trpc/routers/users.ts10
-rw-r--r--pnpm-lock.yaml98
20 files changed, 610 insertions, 5 deletions
diff --git a/apps/web/app/settings/info/page.tsx b/apps/web/app/settings/info/page.tsx
index b42c1a28..da9b2e51 100644
--- a/apps/web/app/settings/info/page.tsx
+++ b/apps/web/app/settings/info/page.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { ChangePassword } from "@/components/settings/ChangePassword";
import { DeleteAccount } from "@/components/settings/DeleteAccount";
import ReaderSettings from "@/components/settings/ReaderSettings";
+import UserAvatar from "@/components/settings/UserAvatar";
import UserDetails from "@/components/settings/UserDetails";
import UserOptions from "@/components/settings/UserOptions";
import { useTranslation } from "@/lib/i18n/server";
@@ -17,6 +18,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function InfoPage() {
return (
<div className="flex flex-col gap-4">
+ <UserAvatar />
<UserDetails />
<ChangePassword />
<UserOptions />
diff --git a/apps/web/components/dashboard/header/ProfileOptions.tsx b/apps/web/components/dashboard/header/ProfileOptions.tsx
index 7ccc0078..5199bdec 100644
--- a/apps/web/components/dashboard/header/ProfileOptions.tsx
+++ b/apps/web/components/dashboard/header/ProfileOptions.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useMemo } from "react";
import Link from "next/link";
import { redirect, useRouter } from "next/navigation";
import { useToggleTheme } from "@/components/theme-provider";
@@ -11,11 +12,14 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
+import { UserAvatar } from "@/components/ui/user-avatar";
import { useTranslation } from "@/lib/i18n/client";
import { LogOut, Moon, Paintbrush, Settings, Shield, Sun } from "lucide-react";
import { useSession } from "next-auth/react";
import { useTheme } from "next-themes";
+import { useWhoAmI } from "@karakeep/shared-react/hooks/users";
+
import { AdminNoticeBadge } from "../../admin/AdminNotices";
function DarkModeToggle() {
@@ -43,7 +47,12 @@ export default function SidebarProfileOptions() {
const { t } = useTranslation();
const toggleTheme = useToggleTheme();
const { data: session } = useSession();
+ const { data: whoami } = useWhoAmI();
const router = useRouter();
+
+ const avatarImage = whoami?.image ?? null;
+ const avatarUrl = useMemo(() => avatarImage ?? null, [avatarImage]);
+
if (!session) return redirect("/");
return (
@@ -53,13 +62,21 @@ export default function SidebarProfileOptions() {
className="border-new-gray-200 aspect-square rounded-full border-4 bg-black p-0 text-white"
variant="ghost"
>
- {session.user.name?.charAt(0) ?? "U"}
+ <UserAvatar
+ image={avatarUrl}
+ name={session.user.name}
+ className="h-full w-full rounded-full"
+ />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="mr-2 min-w-64 p-2">
<div className="flex gap-2">
- <div className="border-new-gray-200 flex aspect-square size-11 items-center justify-center rounded-full border-4 bg-black p-0 text-white">
- {session.user.name?.charAt(0) ?? "U"}
+ <div className="border-new-gray-200 flex aspect-square size-11 items-center justify-center overflow-hidden rounded-full border-4 bg-black p-0 text-white">
+ <UserAvatar
+ image={avatarUrl}
+ name={session.user.name}
+ className="h-full w-full"
+ />
</div>
<div className="flex flex-col">
<p>{session.user.name}</p>
diff --git a/apps/web/components/settings/UserAvatar.tsx b/apps/web/components/settings/UserAvatar.tsx
new file mode 100644
index 00000000..fd773697
--- /dev/null
+++ b/apps/web/components/settings/UserAvatar.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import type { ChangeEvent } from "react";
+import { useRef } from "react";
+import { ActionButton } from "@/components/ui/action-button";
+import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
+import { UserAvatar as UserAvatarImage } from "@/components/ui/user-avatar";
+import useUpload from "@/lib/hooks/upload-file";
+import { useTranslation } from "@/lib/i18n/client";
+import { Image as ImageIcon, Upload, User, X } from "lucide-react";
+
+import {
+ useUpdateUserAvatar,
+ useWhoAmI,
+} from "@karakeep/shared-react/hooks/users";
+
+import { Button } from "../ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
+import { toast } from "../ui/use-toast";
+
+export default function UserAvatar() {
+ const { t } = useTranslation();
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const whoami = useWhoAmI();
+ const image = whoami.data?.image ?? null;
+
+ const updateAvatar = useUpdateUserAvatar({
+ onError: () => {
+ toast({
+ description: t("common.something_went_wrong"),
+ variant: "destructive",
+ });
+ },
+ });
+
+ const upload = useUpload({
+ onSuccess: async (resp) => {
+ try {
+ await updateAvatar.mutateAsync({ assetId: resp.assetId });
+ toast({
+ description: t("settings.info.avatar.updated"),
+ });
+ } catch {
+ // handled in onError
+ }
+ },
+ onError: (err) => {
+ toast({
+ description: err.error,
+ variant: "destructive",
+ });
+ },
+ });
+
+ const isBusy = upload.isPending || updateAvatar.isPending;
+
+ const handleSelectFile = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0];
+ if (!file) {
+ return;
+ }
+ upload.mutate(file);
+ event.target.value = "";
+ };
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2 text-xl">
+ <ImageIcon className="h-5 w-5" />
+ {t("settings.info.avatar.title")}
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <p className="text-sm text-muted-foreground">
+ {t("settings.info.avatar.description")}
+ </p>
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+ <div className="flex items-center gap-4">
+ <div className="flex size-16 items-center justify-center overflow-hidden rounded-full border bg-muted">
+ <UserAvatarImage
+ image={image}
+ name={t("settings.info.avatar.title")}
+ fallback={<User className="h-7 w-7 text-muted-foreground" />}
+ className="h-full w-full"
+ />
+ </div>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept="image/*"
+ className="hidden"
+ onChange={handleFileChange}
+ />
+ <ActionButton
+ type="button"
+ variant="secondary"
+ onClick={handleSelectFile}
+ loading={upload.isPending}
+ disabled={isBusy}
+ >
+ <Upload className="mr-2 h-4 w-4" />
+ {image
+ ? t("settings.info.avatar.change")
+ : t("settings.info.avatar.upload")}
+ </ActionButton>
+ </div>
+ <ActionConfirmingDialog
+ title={t("settings.info.avatar.remove_confirm_title")}
+ description={
+ <p>{t("settings.info.avatar.remove_confirm_description")}</p>
+ }
+ actionButton={(setDialogOpen) => (
+ <ActionButton
+ type="button"
+ variant="destructive"
+ loading={updateAvatar.isPending}
+ onClick={() =>
+ updateAvatar.mutate(
+ { assetId: null },
+ {
+ onSuccess: () => {
+ toast({
+ description: t("settings.info.avatar.removed"),
+ });
+ setDialogOpen(false);
+ },
+ },
+ )
+ }
+ >
+ {t("settings.info.avatar.remove")}
+ </ActionButton>
+ )}
+ >
+ <Button type="button" variant="outline" disabled={!image || isBusy}>
+ <X className="mr-2 h-4 w-4" />
+ {t("settings.info.avatar.remove")}
+ </Button>
+ </ActionConfirmingDialog>
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/apps/web/components/ui/avatar.tsx b/apps/web/components/ui/avatar.tsx
new file mode 100644
index 00000000..7afec626
--- /dev/null
+++ b/apps/web/components/ui/avatar.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "@/lib/utils";
+import * as AvatarPrimitive from "@radix-ui/react-avatar";
+
+const Avatar = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Root
+ ref={ref}
+ className={cn(
+ "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
+ className,
+ )}
+ {...props}
+ />
+));
+Avatar.displayName = AvatarPrimitive.Root.displayName;
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Image>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Image
+ ref={ref}
+ className={cn("aspect-square h-full w-full", className)}
+ {...props}
+ />
+));
+AvatarImage.displayName = AvatarPrimitive.Image.displayName;
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Fallback>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Fallback
+ ref={ref}
+ className={cn(
+ "flex h-full w-full items-center justify-center rounded-full bg-muted",
+ className,
+ )}
+ {...props}
+ />
+));
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/apps/web/components/ui/user-avatar.tsx b/apps/web/components/ui/user-avatar.tsx
new file mode 100644
index 00000000..4ebb6ec3
--- /dev/null
+++ b/apps/web/components/ui/user-avatar.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useMemo } from "react";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { cn } from "@/lib/utils";
+
+import { getAssetUrl } from "@karakeep/shared/utils/assetUtils";
+
+interface UserAvatarProps {
+ image?: string | null;
+ name?: string | null;
+ className?: string;
+ imgClassName?: string;
+ fallbackClassName?: string;
+ fallback?: React.ReactNode;
+}
+
+const isExternalUrl = (value: string) =>
+ value.startsWith("http://") || value.startsWith("https://");
+
+export function UserAvatar({
+ image,
+ name,
+ className,
+ imgClassName,
+ fallbackClassName,
+ fallback,
+}: UserAvatarProps) {
+ const avatarUrl = useMemo(() => {
+ if (!image) {
+ return null;
+ }
+ return isExternalUrl(image) ? image : getAssetUrl(image);
+ }, [image]);
+
+ const fallbackContent = fallback ?? name?.charAt(0) ?? "U";
+
+ return (
+ <Avatar className={className}>
+ {avatarUrl && (
+ <AvatarImage
+ src={avatarUrl}
+ alt={name ?? "User"}
+ className={cn("object-cover", imgClassName)}
+ />
+ )}
+ <AvatarFallback className={cn("text-sm font-medium", fallbackClassName)}>
+ {fallbackContent}
+ </AvatarFallback>
+ </Avatar>
+ );
+}
diff --git a/apps/web/lib/attachments.tsx b/apps/web/lib/attachments.tsx
index 67941098..81b9f12d 100644
--- a/apps/web/lib/attachments.tsx
+++ b/apps/web/lib/attachments.tsx
@@ -4,6 +4,7 @@ import {
FileCode,
Image,
Paperclip,
+ SquareUser,
Upload,
Video,
} from "lucide-react";
@@ -20,5 +21,6 @@ export const ASSET_TYPE_TO_ICON: Record<ZAssetType, React.ReactNode> = {
bookmarkAsset: <Paperclip className="size-4" />,
linkHtmlContent: <FileCode className="size-4" />,
userUploaded: <Upload className="size-4" />,
+ avatar: <SquareUser className="size-4" />,
unknown: <Paperclip className="size-4" />,
};
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 672d3e58..a4b94e4b 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -120,6 +120,17 @@
"confirm_new_password": "Confirm New Password",
"options": "Options",
"interface_lang": "Interface Language",
+ "avatar": {
+ "title": "Profile Photo",
+ "description": "Upload a square image to use as your avatar.",
+ "upload": "Upload avatar",
+ "change": "Change avatar",
+ "remove": "Remove avatar",
+ "remove_confirm_title": "Remove avatar?",
+ "remove_confirm_description": "This will clear your current profile photo.",
+ "updated": "Avatar updated",
+ "removed": "Avatar removed"
+ },
"user_settings": {
"user_settings_updated": "User settings have been updated!",
"bookmark_click_action": {
diff --git a/apps/web/lib/i18n/locales/en_US/translation.json b/apps/web/lib/i18n/locales/en_US/translation.json
index 12af64e8..6c3dd62b 100644
--- a/apps/web/lib/i18n/locales/en_US/translation.json
+++ b/apps/web/lib/i18n/locales/en_US/translation.json
@@ -200,6 +200,17 @@
"confirm_new_password": "Confirm New Password",
"options": "Options",
"interface_lang": "Interface Language",
+ "avatar": {
+ "title": "Profile Photo",
+ "description": "Upload a square image to use as your avatar.",
+ "upload": "Upload avatar",
+ "change": "Change avatar",
+ "remove": "Remove avatar",
+ "remove_confirm_title": "Remove avatar?",
+ "remove_confirm_description": "This will clear your current profile photo.",
+ "updated": "Avatar updated",
+ "removed": "Avatar removed"
+ },
"user_settings": {
"user_settings_updated": "User settings have been updated!",
"bookmark_click_action": {
diff --git a/apps/web/package.json b/apps/web/package.json
index 37cbe940..5f8eff0d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -33,6 +33,7 @@
"@lexical/react": "^0.20.2",
"@lexical/rich-text": "^0.20.2",
"@marsidev/react-turnstile": "^1.3.1",
+ "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
diff --git a/apps/web/server/auth.ts b/apps/web/server/auth.ts
index 833cf174..52c5e9b3 100644
--- a/apps/web/server/auth.ts
+++ b/apps/web/server/auth.ts
@@ -141,7 +141,6 @@ if (oauth.wellKnownUrl) {
id: profile.sub,
name: profile.name || profile.email,
email: profile.email,
- image: profile.picture,
role: admin || firstUser ? "admin" : "user",
};
},
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 278da072..2c2a997c 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -266,6 +266,7 @@ export const enum AssetTypes {
LINK_HTML_CONTENT = "linkHtmlContent",
BOOKMARK_ASSET = "bookmarkAsset",
USER_UPLOADED = "userUploaded",
+ AVATAR = "avatar",
BACKUP = "backup",
UNKNOWN = "unknown",
}
@@ -286,6 +287,7 @@ export const assets = sqliteTable(
AssetTypes.LINK_HTML_CONTENT,
AssetTypes.BOOKMARK_ASSET,
AssetTypes.USER_UPLOADED,
+ AssetTypes.AVATAR,
AssetTypes.BACKUP,
AssetTypes.UNKNOWN,
],
diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json
index 4532cd98..505cdfc2 100644
--- a/packages/open-api/karakeep-openapi-spec.json
+++ b/packages/open-api/karakeep-openapi-spec.json
@@ -325,6 +325,7 @@
"bookmarkAsset",
"precrawledArchive",
"userUploaded",
+ "avatar",
"unknown"
]
},
@@ -1747,6 +1748,7 @@
"bookmarkAsset",
"precrawledArchive",
"userUploaded",
+ "avatar",
"unknown"
]
}
@@ -1782,6 +1784,7 @@
"bookmarkAsset",
"precrawledArchive",
"userUploaded",
+ "avatar",
"unknown"
]
},
@@ -3248,6 +3251,10 @@
"type": "string",
"nullable": true
},
+ "image": {
+ "type": "string",
+ "nullable": true
+ },
"localUser": {
"type": "boolean"
}
diff --git a/packages/shared-react/hooks/users.ts b/packages/shared-react/hooks/users.ts
index eecde3f1..b1909761 100644
--- a/packages/shared-react/hooks/users.ts
+++ b/packages/shared-react/hooks/users.ts
@@ -13,6 +13,19 @@ export function useUpdateUserSettings(
});
}
+export function useUpdateUserAvatar(
+ ...opts: Parameters<typeof api.users.updateAvatar.useMutation>
+) {
+ const apiUtils = api.useUtils();
+ return api.users.updateAvatar.useMutation({
+ ...opts[0],
+ onSuccess: (res, req, meta, context) => {
+ apiUtils.users.whoami.invalidate();
+ return opts[0]?.onSuccess?.(res, req, meta, context);
+ },
+ });
+}
+
export function useDeleteAccount(
...opts: Parameters<typeof api.users.deleteAccount.useMutation>
) {
diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts
index cbaa4574..8a294422 100644
--- a/packages/shared/types/bookmarks.ts
+++ b/packages/shared/types/bookmarks.ts
@@ -25,6 +25,7 @@ export const zAssetTypesSchema = z.enum([
"bookmarkAsset",
"precrawledArchive",
"userUploaded",
+ "avatar",
"unknown",
]);
export type ZAssetType = z.infer<typeof zAssetTypesSchema>;
diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts
index d4fff9a1..7338ee15 100644
--- a/packages/shared/types/users.ts
+++ b/packages/shared/types/users.ts
@@ -38,6 +38,7 @@ export const zWhoAmIResponseSchema = z.object({
id: z.string(),
name: z.string().nullish(),
email: z.string().nullish(),
+ image: z.string().nullish(),
localUser: z.boolean(),
});
diff --git a/packages/trpc/lib/attachments.ts b/packages/trpc/lib/attachments.ts
index 25d9be94..fb9e2079 100644
--- a/packages/trpc/lib/attachments.ts
+++ b/packages/trpc/lib/attachments.ts
@@ -17,6 +17,7 @@ export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType {
[AssetTypes.LINK_HTML_CONTENT]: "linkHtmlContent",
[AssetTypes.BOOKMARK_ASSET]: "bookmarkAsset",
[AssetTypes.USER_UPLOADED]: "userUploaded",
+ [AssetTypes.AVATAR]: "avatar",
[AssetTypes.BACKUP]: "unknown", // Backups are not displayed as regular assets
[AssetTypes.UNKNOWN]: "bannerImage",
};
@@ -36,6 +37,7 @@ export function mapSchemaAssetTypeToDB(
bookmarkAsset: AssetTypes.BOOKMARK_ASSET,
linkHtmlContent: AssetTypes.LINK_HTML_CONTENT,
userUploaded: AssetTypes.USER_UPLOADED,
+ avatar: AssetTypes.AVATAR,
unknown: AssetTypes.UNKNOWN,
};
return map[assetType];
@@ -52,6 +54,7 @@ export function humanFriendlyNameForAssertType(type: ZAssetType) {
bookmarkAsset: "Bookmark Asset",
linkHtmlContent: "HTML Content",
userUploaded: "User Uploaded File",
+ avatar: "Avatar",
unknown: "Unknown",
};
return map[type];
@@ -68,6 +71,7 @@ export function isAllowedToAttachAsset(type: ZAssetType) {
bookmarkAsset: false,
linkHtmlContent: false,
userUploaded: true,
+ avatar: false,
unknown: false,
};
return map[type];
@@ -84,6 +88,7 @@ export function isAllowedToDetachAsset(type: ZAssetType) {
bookmarkAsset: false,
linkHtmlContent: false,
userUploaded: true,
+ avatar: false,
unknown: false,
};
return map[type];
diff --git a/packages/trpc/models/users.ts b/packages/trpc/models/users.ts
index 4c7272b1..0653349b 100644
--- a/packages/trpc/models/users.ts
+++ b/packages/trpc/models/users.ts
@@ -7,6 +7,7 @@ import { z } from "zod";
import { SqliteError } from "@karakeep/db";
import {
assets,
+ AssetTypes,
bookmarkLinks,
bookmarkLists,
bookmarks,
@@ -17,7 +18,7 @@ import {
users,
verificationTokens,
} from "@karakeep/db/schema";
-import { deleteUserAssets } from "@karakeep/shared/assetdb";
+import { deleteAsset, deleteUserAssets } from "@karakeep/shared/assetdb";
import serverConfig from "@karakeep/shared/config";
import {
zResetPasswordSchema,
@@ -491,6 +492,104 @@ export class User {
.where(eq(users.id, this.user.id));
}
+ async updateAvatar(assetId: string | null): Promise<void> {
+ const previousImage = this.user.image ?? null;
+ const [asset, previousAsset] = await Promise.all([
+ assetId
+ ? this.ctx.db.query.assets.findFirst({
+ where: and(eq(assets.id, assetId), eq(assets.userId, this.user.id)),
+ columns: {
+ id: true,
+ bookmarkId: true,
+ contentType: true,
+ assetType: true,
+ },
+ })
+ : Promise.resolve(null),
+ previousImage && previousImage !== assetId
+ ? this.ctx.db.query.assets.findFirst({
+ where: and(
+ eq(assets.id, previousImage),
+ eq(assets.userId, this.user.id),
+ ),
+ columns: {
+ id: true,
+ bookmarkId: true,
+ },
+ })
+ : Promise.resolve(null),
+ ]);
+
+ if (assetId) {
+ if (!asset) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Avatar asset not found",
+ });
+ }
+
+ if (asset.bookmarkId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Avatar asset must not be attached to a bookmark",
+ });
+ }
+
+ if (asset.contentType && !asset.contentType.startsWith("image/")) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Avatar asset must be an image",
+ });
+ }
+
+ if (
+ asset.assetType !== AssetTypes.AVATAR &&
+ asset.assetType !== AssetTypes.UNKNOWN
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Avatar asset type is not supported",
+ });
+ }
+
+ if (asset.assetType !== AssetTypes.AVATAR) {
+ await this.ctx.db
+ .update(assets)
+ .set({ assetType: AssetTypes.AVATAR })
+ .where(eq(assets.id, asset.id));
+ }
+ }
+ if (previousImage === assetId) {
+ return;
+ }
+
+ await this.ctx.db.transaction(async (tx) => {
+ await tx
+ .update(users)
+ .set({ image: assetId })
+ .where(eq(users.id, this.user.id));
+
+ if (!previousImage || previousImage === assetId) {
+ return;
+ }
+
+ if (previousAsset && !previousAsset.bookmarkId) {
+ await tx.delete(assets).where(eq(assets.id, previousAsset.id));
+ }
+ });
+
+ this.user.image = assetId;
+
+ if (!previousImage || previousImage === assetId) {
+ return;
+ }
+
+ await deleteAsset({
+ userId: this.user.id,
+ assetId: previousImage,
+ }).catch(() => ({}));
+ }
+
async getStats(): Promise<z.infer<typeof zUserStatsResponseSchema>> {
const userObj = await this.ctx.db.query.users.findFirst({
where: eq(users.id, this.user.id),
@@ -770,6 +869,7 @@ export class User {
id: this.user.id,
name: this.user.name,
email: this.user.email,
+ image: this.user.image,
localUser: this.user.password !== null,
};
}
diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts
index 27d6c2d6..21f05f5b 100644
--- a/packages/trpc/routers/users.test.ts
+++ b/packages/trpc/routers/users.test.ts
@@ -942,6 +942,81 @@ describe("User Routes", () => {
});
});
+ describe("Update Avatar", () => {
+ test<CustomTestContext>("updateAvatar - promotes unknown asset", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Avatar Reject",
+ email: "avatar-reject@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const caller = getApiCaller(db, user.id, user.email, user.role || "user");
+
+ await db.insert(assets).values({
+ id: "avatar-asset-2",
+ assetType: AssetTypes.UNKNOWN,
+ userId: user.id,
+ contentType: "image/png",
+ size: 12,
+ fileName: "avatar.png",
+ bookmarkId: null,
+ });
+
+ await caller.users.updateAvatar({ assetId: "avatar-asset-2" });
+
+ const updatedAsset = await db
+ .select()
+ .from(assets)
+ .where(eq(assets.id, "avatar-asset-2"))
+ .then((rows) => rows[0]);
+
+ expect(updatedAsset?.assetType).toBe(AssetTypes.AVATAR);
+ });
+
+ test<CustomTestContext>("updateAvatar - deletes avatar asset", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Avatar Delete",
+ email: "avatar-delete@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const caller = getApiCaller(db, user.id, user.email, user.role || "user");
+
+ await db.insert(assets).values({
+ id: "avatar-asset-3",
+ assetType: AssetTypes.UNKNOWN,
+ userId: user.id,
+ contentType: "image/png",
+ size: 12,
+ fileName: "avatar.png",
+ bookmarkId: null,
+ });
+
+ await caller.users.updateAvatar({ assetId: "avatar-asset-3" });
+ await caller.users.updateAvatar({ assetId: null });
+
+ const updatedUser = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, user.id))
+ .then((rows) => rows[0]);
+ const remainingAsset = await db
+ .select()
+ .from(assets)
+ .where(eq(assets.id, "avatar-asset-3"))
+ .then((rows) => rows[0]);
+
+ expect(updatedUser?.image).toBeNull();
+ expect(remainingAsset).toBeUndefined();
+ });
+ });
+
describe("Who Am I", () => {
test<CustomTestContext>("whoami - returns user info", async ({
db,
diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts
index d3bc06d9..dbfbbc3c 100644
--- a/packages/trpc/routers/users.ts
+++ b/packages/trpc/routers/users.ts
@@ -148,6 +148,16 @@ export const usersAppRouter = router({
const user = await User.fromCtx(ctx);
await user.updateSettings(input);
}),
+ updateAvatar: authedProcedure
+ .input(
+ z.object({
+ assetId: z.string().nullable(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const user = await User.fromCtx(ctx);
+ await user.updateAvatar(input.assetId);
+ }),
verifyEmail: publicProcedure
.use(
createRateLimitMiddleware({
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index febde7ba..4a782e3b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -540,6 +540,9 @@ importers:
'@marsidev/react-turnstile':
specifier: ^1.3.1
version: 1.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-avatar':
+ specifier: ^1.1.11
+ version: 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-collapsible':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -4391,6 +4394,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-avatar@1.1.11':
+ resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-collapsible@1.1.11':
resolution: {integrity: sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==}
peerDependencies:
@@ -4435,6 +4451,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-context@1.1.3':
+ resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-dialog@1.1.14':
resolution: {integrity: sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==}
peerDependencies:
@@ -4605,6 +4630,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-primitive@2.1.4':
+ resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-progress@1.1.7':
resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==}
peerDependencies:
@@ -4701,6 +4739,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-slot@1.2.4':
+ resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-switch@1.2.5':
resolution: {integrity: sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ==}
peerDependencies:
@@ -4802,6 +4849,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-use-is-hydrated@0.1.0':
+ resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-use-layout-effect@1.1.1':
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
peerDependencies:
@@ -19530,6 +19586,19 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
+ '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@radix-ui/react-context': 1.1.3(@types/react@19.1.8)(react@19.1.0)
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.8)(react@19.1.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ optionalDependencies:
+ '@types/react': 19.1.8
+ '@types/react-dom': 19.1.6(@types/react@19.1.8)
+
'@radix-ui/react-collapsible@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -19570,6 +19639,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.8
+ '@radix-ui/react-context@1.1.3(@types/react@19.1.8)(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+ optionalDependencies:
+ '@types/react': 19.1.8
+
'@radix-ui/react-dialog@1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -19755,6 +19830,15 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
+ '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.4(@types/react@19.1.8)(react@19.1.0)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ optionalDependencies:
+ '@types/react': 19.1.8
+ '@types/react-dom': 19.1.6(@types/react@19.1.8)
+
'@radix-ui/react-progress@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
@@ -19870,6 +19954,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.8
+ '@radix-ui/react-slot@1.2.4(@types/react@19.1.8)(react@19.1.0)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
+ react: 19.1.0
+ optionalDependencies:
+ '@types/react': 19.1.8
+
'@radix-ui/react-switch@1.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -19980,6 +20071,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.8
+ '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.1.8)(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+ use-sync-external-store: 1.5.0(react@19.1.0)
+ optionalDependencies:
+ '@types/react': 19.1.8
+
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.8)(react@19.1.0)':
dependencies:
react: 19.1.0