Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
de56456
Add profile picture editor
BoraIneviNMI Jul 15, 2026
972e3ec
Add user profile info management
BoraIneviNMI Jul 16, 2026
43ddeb3
Add organization logo management
BoraIneviNMI Jul 17, 2026
7b44f18
Move image cropping to client-side
BoraIneviNMI Jul 17, 2026
fd77766
Add pixel size checks
BoraIneviNMI Jul 17, 2026
f4aa43a
Add welcome page profile picture input
BoraIneviNMI Jul 20, 2026
90f05ff
fix: update principal_id resolution in the profile picture route
BoraIneviNMI Jul 20, 2026
eea55ef
Resolve nitpicks
BoraIneviNMI Jul 20, 2026
05e5bbe
Add read-only version of the organization profile picture setting
BoraIneviNMI Jul 20, 2026
83bbd53
Add changeset
BoraIneviNMI Jul 20, 2026
ee27339
Merge branch 'development' into FDM664
BoraIneviNMI Jul 20, 2026
7b64f7b
Allow cropping organization logos beyond the bounds of the image
BoraIneviNMI Jul 20, 2026
9dd00e1
Add organization logo to the organization cards and the organization …
BoraIneviNMI Jul 21, 2026
23554bb
Merge branch 'development' into FDM664
BoraIneviNMI Jul 21, 2026
4c4958d
Fix organization card organization name type
BoraIneviNMI Jul 21, 2026
b2b9e4a
Improve organization settings page
BoraIneviNMI Jul 21, 2026
112ecc1
Fix potential bugs
BoraIneviNMI Jul 21, 2026
11ef86e
fix: reset profile picture manager after it is submitted
BoraIneviNMI Jul 23, 2026
9f48cb0
Merge branch 'development' into FDM664
SvenVw Jul 23, 2026
3a07ee5
fix: uploading profile picture
SvenVw Jul 23, 2026
4d482a8
refactor: show only single back button on page
SvenVw Jul 23, 2026
9162538
refactor: use rectangle and outer for user profile
SvenVw Jul 23, 2026
b7e8cd0
Merge branch 'development' into FDM664
SvenVw Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gold-actors-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nmi-agro/fdm-app": minor
---

Users can now upload their profile picture and organizations can now upload their logo, which will be stored in the GCS and displayed using the existing user.image and organization.logo database record fields. The profile pictures and logos, including those retrieved previously from social login, can also be deleted.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Scope the social-login deletion claim to user profile pictures.

The supplied flow demonstrates removal of a user’s imported social image, but not social-login-derived organization logos. Reword this sentence to avoid implying that organization logos can originate from social login.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/gold-actors-type.md at line 5, Update the changelog sentence in
gold-actors-type.md to scope the social-login deletion claim to user profile
pictures only; retain the deletion claim for organization logos without implying
they can originate from social login.

1 change: 1 addition & 0 deletions fdm-app/app/components/blocks/mijnpercelen/form-upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export function MijnPercelenUploadForm({
onBlur={onBlur}
onFilesChange={handleFilesSet}
className={cn(
"h-32",
hasAllRequiredFiles && "border-green-500 bg-green-50",
uploadState === "error" && "border-red-500 bg-red-50",
uploadState === "success" && "border-green-500 bg-green-50",
Expand Down
92 changes: 82 additions & 10 deletions fdm-app/app/components/blocks/organization/form.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect } from "react"
import { Building, X } from "lucide-react"
import { useEffect, useRef, useState, useTransition } from "react"
import { Controller } from "react-hook-form"
import { Form, type HTMLFormMethod } from "react-router"
import { useFetcher, type HTMLFormMethod } from "react-router"
import { RemixFormProvider, useRemixForm } from "remix-hook-form"
import z from "zod"
import type { ParsedOrganization } from "~/lib/organization-helpers"
import {
cropProfilePicture,
MAX_SIZE_BYTES,
ProfilePictureInput,
} from "~/components/blocks/profile/profile-picture-manager"
import { Button } from "~/components/ui/button"
import {
Card,
Expand All @@ -17,23 +24,51 @@ import { Field, FieldError, FieldLabel } from "~/components/ui/field"
import { Input } from "~/components/ui/input"
import { Spinner } from "~/components/ui/spinner"
import { Textarea } from "~/components/ui/textarea"
import { FormSchema } from "./schema"
import { OrganizationInfoSchema } from "./schema"

const FormSchema = OrganizationInfoSchema.extend({ intent: z.literal("update_organization_info") })
export function OrganizationSettingsForm({
className,
organization,
action,
method = "POST",
canModify,
profilePictureField,
}: {
className?: string
organization?: ParsedOrganization
action?: string
method?: HTMLFormMethod
canModify: boolean
profilePictureField: boolean
}) {
const fetcher = useFetcher()

const [profilePictureFiles, setProfilePictureFiles] = useState<File[]>([])

const [isProcessingForm, processForm] = useTransition()

const formRef = useRef<HTMLFormElement>(null)
const form = useRemixForm({
mode: "onTouched",
resolver: zodResolver(FormSchema),
fetcher: fetcher,
stringifyAllValues: false,
submitHandlers: {
async onValid() {
if (!formRef.current) return
const formData = new FormData(formRef.current)
processForm(async () => {
const formDataWithCroppedPic = await cropProfilePicture(formData)
fetcher.submit(formDataWithCroppedPic, {
method: "post",
encType: "multipart/form-data",
})
})
},
},
defaultValues: {
intent: "update_organization_info" as const,
name: organization?.name,
slug: organization?.slug,
description: organization?.metadata?.data?.description,
Expand All @@ -52,10 +87,12 @@ export function OrganizationSettingsForm({
// Reset the form when the organization changes
useEffect(() => {
form.reset({
intent: "update_organization_info" as const,
name: organization?.name,
slug: organization?.slug,
description: organization?.metadata?.data?.description,
})
setProfilePictureFiles([])
}, [form.reset, !!organization, organization?.slug])

// Update slug when name changes
Expand All @@ -69,24 +106,26 @@ export function OrganizationSettingsForm({
}
}, [organizationName, form.getValues, form.setValue])

const disabled = !canModify || form.formState.isSubmitting
const isSubmitting = isProcessingForm || fetcher.state !== "idle"
const disabled = !canModify || isSubmitting
return (
<Card className="mx-auto max-w-3xl">
<Card className={className}>
<RemixFormProvider {...form}>
<Form action={action} method={method} onSubmit={form.handleSubmit}>
<fetcher.Form ref={formRef} action={action} method={method} onSubmit={form.handleSubmit}>
<CardHeader>
<CardTitle>Organisatiegegevens</CardTitle>
<CardDescription>Voer de gegevens van je organisatie in.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<fieldset disabled={disabled} className="space-y-4">
<input type="hidden" name="intent" value="update_organization_info" />
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>Naam organisatie</FieldLabel>
<Input {...field} type="text" required />
<Input {...field} value={field.value ?? ""} type="text" required />
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
Expand All @@ -97,7 +136,13 @@ export function OrganizationSettingsForm({
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>Organisatie ID</FieldLabel>
<Input {...field} type="text" disabled />
<Input
{...field}
value={field.value ?? ""}
type="text"
readOnly
className="text-muted-foreground pointer-events-none"
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
Expand All @@ -112,20 +157,47 @@ export function OrganizationSettingsForm({
placeholder="Een korte toelichting op je organisatie zodat andere gebruikers er meer te weten over komen."
className="resize-none"
{...field}
value={field.value ?? ""}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
{profilePictureField && (
<Field>
<FieldLabel>Logo (optioneel)</FieldLabel>
<div className="relative mx-auto max-w-sm">
<ProfilePictureInput
appAspectRatio={3 / 2}
files={profilePictureFiles}
onFilesChange={setProfilePictureFiles}
maxFileSize={MAX_SIZE_BYTES}
avatarFallback={<Building className="text-muted-foreground size-3/4" />}
/>
{profilePictureFiles.length > 0 ? (
<Button
variant="ghost"
type="button"
onClick={() => setProfilePictureFiles([])}
className="hover:text-destructive absolute top-2 right-2 size-auto text-gray-100 has-[>svg]:px-1 has-[>svg]:py-1"
title="Logo verwijderen"
aria-label="Logo verwijderen"
>
<X className="h-4 w-4" />
</Button>
) : null}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
</Field>
)}
</fieldset>
</CardContent>
<CardFooter className="flex-row justify-end">
<Button type="submit" disabled={disabled}>
{form.formState.isSubmitting && <Spinner />}
{isSubmitting && <Spinner />}
{organization ? "Bijwerken" : "Aanmaken"}
</Button>
</CardFooter>
</Form>
</fetcher.Form>
</RemixFormProvider>
</Card>
)
Expand Down
6 changes: 4 additions & 2 deletions fdm-app/app/components/blocks/organization/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function isValidSlug(slug: string): boolean {
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)
}

export const FormSchema = z.object({
export const OrganizationInfoFields = {
name: z
.string({
error: (issue) =>
Expand All @@ -36,4 +36,6 @@ export const FormSchema = z.object({
error: "ID moet minimaal 3 karakters bevatten, enkel kleine letters, cijfers of '-'",
}),
description: z.string({}).optional(),
})
}

export const OrganizationInfoSchema = z.object(OrganizationInfoFields)
24 changes: 24 additions & 0 deletions fdm-app/app/components/blocks/profile/detect-existing.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Detect if the user might have an existing profile picture in GCS based on the image URL.
* If so, returns the object key for it.
*
* @param imageUrl URL to check
* @returns the object key if the url matches the pattern for a profile picture that is stored in GCS, null otherwise.
*/
export function detectExistingProfilePictureObjectKey(imageUrl: string | null | undefined) {
if (typeof imageUrl !== "string") {
return null
}

const types = ["user", "organization"] as const

for (const type of types) {
const prefix = `/api/profile-picture/${type}/`
if (imageUrl.startsWith(prefix)) {
const fileName = imageUrl.substring(prefix.length).split("?")[0]
return `profile_picture_${type}/${fileName}`
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return null
}
77 changes: 77 additions & 0 deletions fdm-app/app/components/blocks/profile/profile-info-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect } from "react"
import { Controller } from "react-hook-form"
import { Form, useFetcher } from "react-router"
import { RemixFormProvider, useRemixForm } from "remix-hook-form"
import z from "zod"
import { Button } from "~/components/ui/button"
import { Field, FieldError, FieldLabel } from "~/components/ui/field"
import { Input } from "~/components/ui/input"
import { Spinner } from "~/components/ui/spinner"
import { ProfileInfoSchema } from "./profile-info-schema"

const InfoFormSchema = ProfileInfoSchema.extend({ intent: z.literal("update_profile_info") })
export function ProfileInfoForm({
user,
}: {
user: { firstname?: string | null | undefined; surname?: string | null | undefined }
}) {
const fetcher = useFetcher()

const isSubmitting = fetcher.state !== "idle"

const form = useRemixForm({
mode: "onTouched",
resolver: zodResolver(InfoFormSchema),
fetcher: fetcher,
stringifyAllValues: false,
defaultValues: {
intent: "update_profile_info" as const,
firstname: user.firstname ?? "",
surname: user.surname ?? "",
},
})

useEffect(() => {
form.reset({
intent: "update_profile_info" as const,
firstname: user.firstname ?? "",
surname: user.surname ?? "",
})
}, [user, form.reset])

return (
<RemixFormProvider {...form}>
<Form method="post" onSubmit={form.handleSubmit} className="space-y-6">
<fieldset disabled={isSubmitting} className="space-y-4">
<input type="hidden" name="intent" value="update_profile_info" />
<Controller
name="firstname"
render={({ field, fieldState }) => (
<Field>
<FieldLabel>Voornaam</FieldLabel>
<Input {...field} type="text" />
{fieldState.error && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Controller
name="surname"
render={({ field, fieldState }) => (
<Field>
<FieldLabel>Achternaam</FieldLabel>
<Input {...field} type="text" />
{fieldState.error && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
</fieldset>
<div className="flex flex-row items-center justify-end gap-2">
<Button type="submit" disabled={isSubmitting}>
Opslaan{isSubmitting && <Spinner />}
</Button>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</Form>
</RemixFormProvider>
)
}
20 changes: 20 additions & 0 deletions fdm-app/app/components/blocks/profile/profile-info-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import z from "zod"

export const ProfileInfoSchema = z.object({
firstname: z
.string({
error: (issue) => (issue.input === undefined ? "Vul je voornaam in" : undefined),
})
.trim()
.min(1, {
error: "Vul je voornaam in",
}),
surname: z
.string({
error: (issue) => (issue.input === undefined ? "Vul je achternaam in" : undefined),
})
.trim()
.min(1, {
error: "Vul je achternaam in",
}),
})
Loading
Loading