Skip to content

Commit b146e86

Browse files
committed
Add frontend for uploading and removing tenant logo
1 parent 98ef478 commit b146e86

File tree

12 files changed

+458
-44
lines changed

12 files changed

+458
-44
lines changed

application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Modal } from "@repo/ui/components/Modal";
1111
import { TextField } from "@repo/ui/components/TextField";
1212
import { toastQueue } from "@repo/ui/components/Toast";
1313
import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter";
14+
import type { FileUploadMutation } from "@repo/ui/types/FileUpload";
1415
import { useMutation } from "@tanstack/react-query";
1516
import { CameraIcon, MailIcon, Trash2Icon, XIcon } from "lucide-react";
1617
import { useCallback, useContext, useEffect, useRef, useState } from "react";
@@ -66,17 +67,20 @@ export default function UserProfileModal({ isOpen, onOpenChange }: Readonly<Prof
6667
{ body: Schemas["UpdateCurrentUserCommand"] }
6768
>({
6869
mutationFn: async (data) => {
70+
// Handle avatar changes first
6971
if (selectedAvatarFile) {
7072
const formData = new FormData();
7173
formData.append("file", selectedAvatarFile);
72-
// biome-ignore lint/suspicious/noExplicitAny: The client does not support typed file uploads, see https://github.com/openapi-ts/openapi-typescript/issues/1214
73-
await updateAvatarMutation.mutateAsync({ body: formData as any });
74+
await (updateAvatarMutation as unknown as FileUploadMutation).mutateAsync({ body: formData });
7475
} else if (removeAvatarFlag) {
7576
await removeAvatarMutation.mutateAsync({});
7677
setRemoveAvatarFlag(false);
7778
}
7879

80+
// Update user profile data
7981
await updateCurrentUserMutation.mutateAsync(data);
82+
83+
// Refetch to get the updated user data including new avatar URL
8084
const { data: updatedUser } = await refetch();
8185
if (updatedUser) {
8286
updateUserInfo(updatedUser);

application/account-management/WebApp/federated-modules/sideMenu/FederatedSideMenu.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export default function FederatedSideMenu({ currentSystem }: Readonly<FederatedS
2222
mobileMenuAriaLabel={t`Open navigation menu`}
2323
topMenuContent={<MobileMenu currentSystem={currentSystem} onEditProfile={() => setIsProfileModalOpen(true)} />}
2424
tenantName={userInfo?.tenantName}
25+
tenantLogoUrl={userInfo?.tenantLogoUrl ?? undefined}
2526
>
2627
<NavigationMenuItems currentSystem={currentSystem} />
2728
</SideMenu>

application/account-management/WebApp/routes/admin/account/index.tsx

Lines changed: 252 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,277 @@
11
import FederatedSideMenu from "@/federated-modules/sideMenu/FederatedSideMenu";
22
import { TopMenu } from "@/shared/components/topMenu";
3-
import logoWrap from "@/shared/images/logo-wrap.svg";
43
import { UserRole, api } from "@/shared/lib/api/client";
54
import { t } from "@lingui/core/macro";
65
import { Trans } from "@lingui/react/macro";
76
import { AppLayout } from "@repo/ui/components/AppLayout";
87
import { Breadcrumb } from "@repo/ui/components/Breadcrumbs";
98
import { Button } from "@repo/ui/components/Button";
109
import { Form } from "@repo/ui/components/Form";
10+
import { Menu, MenuItem, MenuSeparator, MenuTrigger } from "@repo/ui/components/Menu";
11+
import { TenantLogo } from "@repo/ui/components/TenantLogo";
1112
import { TextField } from "@repo/ui/components/TextField";
1213
import { toastQueue } from "@repo/ui/components/Toast";
1314
import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter";
15+
import type { FileUploadMutation } from "@repo/ui/types/FileUpload";
16+
import { useQueryClient } from "@tanstack/react-query";
1417
import { createFileRoute } from "@tanstack/react-router";
15-
import { Trash2 } from "lucide-react";
16-
import { useEffect, useState } from "react";
17-
import { Separator } from "react-aria-components";
18+
import { CameraIcon, Trash2, Trash2Icon } from "lucide-react";
19+
import type React from "react";
20+
import { useCallback, useEffect, useRef, useState } from "react";
21+
import { FileTrigger, Label, Separator } from "react-aria-components";
1822
import DeleteAccountConfirmation from "./-components/DeleteAccountConfirmation";
1923

2024
export const Route = createFileRoute("/admin/account/")({
2125
component: AccountSettings
2226
});
2327

28+
const MAX_FILE_SIZE = 1024 * 1024; // 1MB in bytes
29+
const ALLOWED_FILE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"]; // Align with backend
30+
31+
// Helper function for file validation
32+
function validateLogoFile(file: File): boolean {
33+
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
34+
alert(t`Please select a JPEG, PNG, GIF, WebP, or SVG image.`);
35+
return false;
36+
}
37+
38+
if (file.size > MAX_FILE_SIZE) {
39+
alert(t`Image must be smaller than 1 MB.`);
40+
return false;
41+
}
42+
43+
return true;
44+
}
45+
46+
// Custom hook for managing logo state
47+
function useLogoManagement(
48+
updateTenantLogoMutation: FileUploadMutation,
49+
removeTenantLogoMutation: { mutateAsync: (params: Record<string, never>) => Promise<unknown> },
50+
refetchTenant: () => void,
51+
queryClient: ReturnType<typeof useQueryClient>,
52+
logoFileInputRef: React.RefObject<HTMLInputElement | null>
53+
) {
54+
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
55+
const [logoMenuOpen, setLogoMenuOpen] = useState(false);
56+
const [shouldClearInput, setShouldClearInput] = useState(false);
57+
58+
// Handle clearing the input when removal is successful
59+
useEffect(() => {
60+
if (shouldClearInput && logoFileInputRef.current) {
61+
logoFileInputRef.current.value = "";
62+
setShouldClearInput(false);
63+
}
64+
}, [shouldClearInput, logoFileInputRef]);
65+
66+
const handleLogoUpload = useCallback(
67+
async (files: FileList | null) => {
68+
const file = files?.[0];
69+
if (!file || !validateLogoFile(file)) {
70+
return;
71+
}
72+
73+
// Create preview
74+
const objectUrl = URL.createObjectURL(file);
75+
setLogoPreviewUrl(objectUrl);
76+
77+
// Upload immediately
78+
const formData = new FormData();
79+
formData.append("file", file);
80+
await updateTenantLogoMutation.mutateAsync({ body: formData });
81+
82+
// Clean up preview after successful upload
83+
URL.revokeObjectURL(objectUrl);
84+
setLogoPreviewUrl(null);
85+
86+
// Invalidate all queries to refresh UI
87+
await queryClient.invalidateQueries();
88+
refetchTenant();
89+
90+
toastQueue.add({
91+
title: t`Success`,
92+
description: t`Logo uploaded successfully`,
93+
variant: "success"
94+
});
95+
},
96+
[updateTenantLogoMutation, refetchTenant, queryClient]
97+
);
98+
99+
const handleLogoRemoval = useCallback(async () => {
100+
await removeTenantLogoMutation.mutateAsync({});
101+
102+
// Invalidate all queries to refresh UI
103+
await queryClient.invalidateQueries();
104+
refetchTenant();
105+
106+
// Trigger input clearing via state
107+
setShouldClearInput(true);
108+
109+
toastQueue.add({
110+
title: t`Success`,
111+
description: t`Logo removed successfully`,
112+
variant: "success"
113+
});
114+
}, [removeTenantLogoMutation, refetchTenant, queryClient]);
115+
116+
const cleanupLogoPreview = useCallback(() => {
117+
if (logoPreviewUrl) {
118+
URL.revokeObjectURL(logoPreviewUrl);
119+
setLogoPreviewUrl(null);
120+
}
121+
}, [logoPreviewUrl]);
122+
123+
return {
124+
logoPreviewUrl,
125+
logoMenuOpen,
126+
setLogoMenuOpen,
127+
handleLogoUpload,
128+
handleLogoRemoval,
129+
cleanupLogoPreview
130+
};
131+
}
132+
133+
// Logo management component
134+
function LogoSection({
135+
tenant,
136+
logoPreviewUrl,
137+
logoMenuOpen,
138+
setLogoMenuOpen,
139+
handleLogoUpload,
140+
handleLogoRemoval,
141+
logoFileInputRef,
142+
isOwner
143+
}: Readonly<{
144+
tenant: { logoUrl?: string | null; name?: string } | null | undefined;
145+
logoPreviewUrl: string | null;
146+
logoMenuOpen: boolean;
147+
setLogoMenuOpen: (open: boolean) => void;
148+
handleLogoUpload: (files: FileList | null) => void;
149+
handleLogoRemoval: () => void;
150+
logoFileInputRef: React.RefObject<HTMLInputElement | null>;
151+
isOwner: boolean;
152+
}>) {
153+
return (
154+
<>
155+
<FileTrigger
156+
ref={logoFileInputRef}
157+
onSelect={(files) => {
158+
setLogoMenuOpen(false);
159+
handleLogoUpload(files);
160+
}}
161+
acceptedFileTypes={ALLOWED_FILE_TYPES}
162+
/>
163+
164+
<Label>
165+
<Trans>Logo</Trans>
166+
</Label>
167+
168+
<MenuTrigger isOpen={logoMenuOpen} onOpenChange={setLogoMenuOpen}>
169+
<Button variant="icon" className="h-16 w-16 rounded-md" aria-label={t`Change logo`} isDisabled={!isOwner}>
170+
{tenant?.logoUrl || logoPreviewUrl ? (
171+
<img
172+
src={logoPreviewUrl ?? tenant?.logoUrl ?? ""}
173+
className="h-full w-full rounded-md object-contain"
174+
alt={t`Logo`}
175+
/>
176+
) : (
177+
<TenantLogo
178+
key={tenant?.logoUrl || "no-logo"}
179+
logoUrl={null}
180+
tenantName={tenant?.name ?? ""}
181+
size="lg"
182+
isRound={false}
183+
className="h-full w-full"
184+
/>
185+
)}
186+
</Button>
187+
<Menu>
188+
<MenuItem
189+
onAction={() => {
190+
logoFileInputRef.current?.click();
191+
}}
192+
>
193+
<CameraIcon className="h-4 w-4" />
194+
<Trans>Upload logo</Trans>
195+
</MenuItem>
196+
{(tenant?.logoUrl || logoPreviewUrl) && (
197+
<>
198+
<MenuSeparator />
199+
<MenuItem
200+
onAction={() => {
201+
setLogoMenuOpen(false);
202+
handleLogoRemoval();
203+
}}
204+
>
205+
<Trash2Icon className="h-4 w-4 text-destructive" />
206+
<span className="text-destructive">
207+
<Trans>Remove logo</Trans>
208+
</span>
209+
</MenuItem>
210+
</>
211+
)}
212+
</Menu>
213+
</MenuTrigger>
214+
</>
215+
);
216+
}
217+
218+
// Danger zone component
219+
function DangerZone({ setIsDeleteModalOpen }: { setIsDeleteModalOpen: (open: boolean) => void }) {
220+
return (
221+
<div className="mt-6 flex flex-col gap-4">
222+
<h2>
223+
<Trans>Danger zone</Trans>
224+
</h2>
225+
<Separator />
226+
<div className="flex flex-col gap-4">
227+
<p>
228+
<Trans>Delete your account and all data. This action is irreversible—proceed with caution.</Trans>
229+
</p>
230+
231+
<Button variant="destructive" onPress={() => setIsDeleteModalOpen(true)} className="w-fit">
232+
<Trash2 />
233+
<Trans>Delete account</Trans>
234+
</Button>
235+
</div>
236+
</div>
237+
);
238+
}
239+
24240
export function AccountSettings() {
25241
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
26-
const { data: tenant, isLoading: tenantLoading } = api.useQuery("get", "/api/account-management/tenants/current");
242+
const logoFileInputRef = useRef<HTMLInputElement>(null);
243+
const queryClient = useQueryClient();
244+
245+
const {
246+
data: tenant,
247+
isLoading: tenantLoading,
248+
refetch: refetchTenant
249+
} = api.useQuery("get", "/api/account-management/tenants/current");
27250
const { data: currentUser, isLoading: userLoading } = api.useQuery("get", "/api/account-management/users/me");
28251
const updateCurrentTenantMutation = api.useMutation("put", "/api/account-management/tenants/current");
252+
const updateTenantLogoMutation = api.useMutation("post", "/api/account-management/tenants/current/update-logo");
253+
const removeTenantLogoMutation = api.useMutation("delete", "/api/account-management/tenants/current/remove-logo");
254+
255+
const { logoPreviewUrl, logoMenuOpen, setLogoMenuOpen, handleLogoUpload, handleLogoRemoval } = useLogoManagement(
256+
updateTenantLogoMutation as unknown as FileUploadMutation,
257+
removeTenantLogoMutation,
258+
refetchTenant,
259+
queryClient,
260+
logoFileInputRef
261+
);
29262

30263
const isOwner = currentUser?.role === UserRole.Owner;
31264

32265
useEffect(() => {
33266
if (updateCurrentTenantMutation.isSuccess) {
34267
toastQueue.add({
35268
title: t`Success`,
36-
description: t`Account updated successfully`,
269+
description: t`Account name updated successfully`,
37270
variant: "success"
38271
});
272+
refetchTenant();
39273
}
40-
}, [updateCurrentTenantMutation.isSuccess]);
274+
}, [updateCurrentTenantMutation.isSuccess, refetchTenant]);
41275

42276
if (tenantLoading || userLoading) {
43277
return null;
@@ -72,9 +306,17 @@ export function AccountSettings() {
72306
</h2>
73307
<Separator />
74308

75-
<Trans>Logo</Trans>
309+
<LogoSection
310+
tenant={tenant}
311+
logoPreviewUrl={logoPreviewUrl}
312+
logoMenuOpen={logoMenuOpen}
313+
setLogoMenuOpen={setLogoMenuOpen}
314+
handleLogoUpload={handleLogoUpload}
315+
handleLogoRemoval={handleLogoRemoval}
316+
logoFileInputRef={logoFileInputRef}
317+
isOwner={isOwner}
318+
/>
76319

77-
<img src={logoWrap} alt={t`Logo`} className="max-h-16 max-w-64" />
78320
<TextField
79321
isRequired={true}
80322
name="name"
@@ -92,24 +334,7 @@ export function AccountSettings() {
92334
)}
93335
</Form>
94336

95-
{isOwner && (
96-
<div className="mt-6 flex flex-col gap-4">
97-
<h2>
98-
<Trans>Danger zone</Trans>
99-
</h2>
100-
<Separator />
101-
<div className="flex flex-col gap-4">
102-
<p>
103-
<Trans>Delete your account and all data. This action is irreversible—proceed with caution.</Trans>
104-
</p>
105-
106-
<Button variant="destructive" onPress={() => setIsDeleteModalOpen(true)} className="w-fit">
107-
<Trash2 />
108-
<Trans>Delete account</Trans>
109-
</Button>
110-
</div>
111-
</div>
112-
)}
337+
{isOwner && <DangerZone setIsDeleteModalOpen={setIsDeleteModalOpen} />}
113338
</AppLayout>
114339

115340
<DeleteAccountConfirmation isOpen={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen} />

0 commit comments

Comments
 (0)