Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ecd4d21
feat: Add the possibilty for users to accept or reject an invitation …
SvenVw Feb 19, 2026
286c44a
fix: enable autocomplete for users not in the list
SvenVw Feb 19, 2026
214d2e4
fix: replace regex check for email with validator package
SvenVw Feb 20, 2026
da9b386
refactor: implement feedback from code review
SvenVw Feb 20, 2026
4a0ae7e
refactor: make invitations generic instead of specific for farms
SvenVw Feb 20, 2026
70f85ae
docs: Add invitations to the page about Authorization
SvenVw Feb 20, 2026
3530577
tests: increase coverage
SvenVw Feb 20, 2026
8cb6751
refactor: improve invite email
SvenVw Feb 20, 2026
93ca97a
feat: add spam prevention measures for invitations
SvenVw Feb 20, 2026
0d953f1
fix: import
SvenVw Feb 20, 2026
b04deff
refactor: improve invitation on farms overview page
SvenVw Feb 20, 2026
a920171
fix: invite existing user by email
SvenVw Feb 20, 2026
3b09093
refactor: improve invitation card
SvenVw Feb 20, 2026
e340d34
Merge branch 'development' into FDM460
SvenVw Feb 20, 2026
4e6f924
refactor: use Font component for consistent fonts across email clients
SvenVw Feb 20, 2026
8da7fc3
refactor: implement review feedback
SvenVw Feb 20, 2026
dfdaa85
test: fixes
SvenVw Feb 20, 2026
3a88532
refactor: implement review feedback
SvenVw Feb 20, 2026
e5b7799
Merge branch 'development' into FDM460
SvenVw Feb 20, 2026
56b3a8c
docs: explain expire parameter
SvenVw Feb 20, 2026
636e07d
fix: remove email from error logs
SvenVw Feb 20, 2026
d1d841a
fix: initials fallback
SvenVw Feb 20, 2026
729eb11
fix: move into transaction
SvenVw Feb 20, 2026
a398cec
refactor: move the pending invitation into a shared component
SvenVw Feb 20, 2026
bdb10c0
fix: handle inactive email recipients by revoking permissions
SvenVw Feb 20, 2026
baa1088
fix: Guard revokePrincipalFromFarm against email-only invite targets …
SvenVw Feb 20, 2026
c370904
refactor: improve text when no user is know and enable to send invita…
SvenVw Feb 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
9 changes: 9 additions & 0 deletions .changeset/farm-invitation-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@svenvw/fdm-app": minor
---

Add the possibility for users to accept or reject an invitation to a farm, instead of having it automatically. This makes it also possible to invite non-registered users to get access to a farm after signing up.

- Overview page shows pending farm invitations with accept/decline actions
- Invitation email sent when a user is invited to a farm
- Farm access settings page handles accept/decline invitation intents
17 changes: 17 additions & 0 deletions .changeset/farm-invitation-system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@svenvw/fdm-core": minor
---

Instead of directly granting roles, `grantRoleToFarm` now creates a pending invitation (7-day expiry) that must be accepted by the target principal. The invitation system has been refactored to be resource-agnostic, so any resource type (farm, field, etc.) can be shared via invitations.

**New generic functions (work for any resource):**
- `createInvitation` — creates a pending invitation for a resource
- `acceptInvitation` — accepts a pending invitation and grants the role
- `declineInvitation` — declines a pending invitation
- `listPendingInvitationsForPrincipal` — lists pending invitations for a principal across all resources
- `autoAcceptInvitationsForNewUser` — auto-accepts email-based invitations on email verification
Comment thread
SvenVw marked this conversation as resolved.

**Farm-specific functions:**
- `listPendingInvitationsForFarm` — lists active invitations for a farm (requires share permission)
- `listPendingInvitationsForUser` — lists pending farm invitations for the current user, enriched with farm name and org name

5 changes: 5 additions & 0 deletions .changeset/tall-buckets-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@svenvw/fdm-docs": minor
---

Add invitations to the page about Authorization
46 changes: 43 additions & 3 deletions fdm-app/app/components/blocks/access/invitation-form.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { User, Users } from "lucide-react"
import { useState } from "react"
import { Form } from "react-router-dom"
import { Form, useSubmit } from "react-router-dom"
import { RemixFormProvider, useRemixForm } from "remix-hook-form"
import isEmail from "validator/lib/isEmail"
import type { z } from "zod"
import { AutoComplete } from "~/components/custom/autocomplete"
import { Button } from "~/components/ui/button"
Expand Down Expand Up @@ -31,6 +32,7 @@ type InvitationFormProps = {
}

export const InvitationForm = ({ principals }: InvitationFormProps) => {
const submit = useSubmit()
const [selectedValue, setSelectedValue] = useState<string>("")
const form = useRemixForm<z.infer<typeof AccessFormSchema>>({
mode: "onTouched",
Expand All @@ -39,14 +41,26 @@ export const InvitationForm = ({ principals }: InvitationFormProps) => {
role: "advisor", // Set default role
intent: "invite_user",
},
submitHandlers: {
onValid: (data) => {
submit(
{
username: data.username,
role: data.role,
intent: "invite_user",
},
{ method: "post" },
)
},
},
})

// Define icon map for AutoComplete
const iconMap = { user: User, organization: Users }

return (
<RemixFormProvider {...form}>
<Form method="post">
<Form method="post" onSubmit={form.handleSubmit}>
<fieldset
disabled={form.formState.isSubmitting}
className="flex items-center justify-between space-x-4"
Expand All @@ -64,8 +78,34 @@ export const InvitationForm = ({ principals }: InvitationFormProps) => {
shouldTouch: true,
})
}}
emptyMessage="Geen gebruikers gevonden"
emptyMessage={(value) =>
isEmail(value) ? (
<button
className="w-full cursor-pointer text-center hover:underline"
onClick={() => {
form.setValue("username", value)
// Trigger form submission programmatically
// This will use the onSubmit handler defined on the Form
;(form.handleSubmit as any)({
preventDefault: () => {},
})
}}
type="button"
>
Nodig {value} uit voor toegang als{" "}
{form.getValues("role") === "owner"
? "eigenaar"
: form.getValues("role") === "advisor"
? "adviseur"
: "onderzoeker"}
.
</button>
) : (
"Geen gebruikers gevonden. Je kunt ook een e-mailadres invoeren om een uitnodiging te sturen."
)
}
placeholder="Zoek naar een gebruiker of organisatie"
allowValuesOutsideList={true}
form={form} // Pass the form instance
name="username" // Name for remix-hook-form registration
/>
Expand Down
133 changes: 133 additions & 0 deletions fdm-app/app/components/blocks/email/farm-invitation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
Body,
Button,
Container,
Font,
Head,
Heading,
Html,
Img,
Link,
Preview,
Section,
Text,
} from "@react-email/components"
import { Tailwind } from "@react-email/tailwind"

interface FarmInvitationEmailProps {
farmName: string
inviterName: string
targetEmail: string
role: string
appName: string
appBaseUrl: string
logoFileName?: string
/** If true, renders a "create account" CTA for unregistered users */
isUnregistered?: boolean
}

const roleLabels: Record<string, string> = {
owner: "Eigenaar",
advisor: "Adviseur",
researcher: "Onderzoeker",
}

export const FarmInvitationEmail = ({
farmName,
inviterName,
targetEmail,
role,
appName,
appBaseUrl,
logoFileName = "/fdm-high-resolution-logo-transparent.png",
isUnregistered = false,
}: FarmInvitationEmailProps) => {
const logoPath = `${appBaseUrl}${logoFileName}`
const roleLabel = roleLabels[role] ?? role

return (
<Html lang="nl">
<Head>
<Font
fontFamily="Inter"
fallbackFontFamily="sans-serif"
fontWeight={400}
fontStyle="normal"
/>
</Head>
<Preview>
{`${inviterName} heeft je uitgenodigd voor toegang tot bedrijf ${farmName} in ${appName}.`}
</Preview>
<Tailwind>
<Body className="bg-white my-auto mx-auto font-sans">
<Container className="border border-solid border-[#eaeaea] rounded my-10 mx-auto p-5 w-116.25">
<Section className="mt-7.5">
<Img
src={logoPath}
width="150"
alt={`${appName} Logo`}
className="my-0 mx-auto"
/>
</Section>
<Heading className="text-black text-[24px] font-normal text-center p-0 my-7.5 mx-0">
Uitnodiging voor <b>{farmName}</b> in {appName}
</Heading>
<Text className="text-black text-[14px] leading-6">
Hallo {targetEmail},
</Text>
<Text className="text-black text-[14px] leading-6">
{inviterName} heeft je uitgenodigd om toegang te
krijgen tot het bedrijf <b>{farmName}</b> in{" "}
{appName} met de rol <b>{roleLabel}</b>.
</Text>
{isUnregistered ? (
<>
<Text className="text-black text-[14px] leading-6">
Maak een account aan om de uitnodiging te
accepteren. Na registratie wordt je toegang
automatisch verleend.
</Text>
<Section className="mt-8 mb-2 text-center">
<Button
href={`${appBaseUrl}/signin`}
className="bg-[#0070f3] text-white border-solid border-[#0070f3] border-2 rounded mx-6 px-3 py-3 text-[14px] font-semibold no-underline"
>
Account aanmaken
</Button>
</Section>
</>
) : (
<>
<Text className="text-black text-[14px] leading-6">
Log in en accepteer of weiger de
uitnodiging.
</Text>
<Section className="mt-8 mb-2 text-center">
<Button
href={`${appBaseUrl}/farm`}
className="bg-[#0070f3] text-white border-solid border-[#0070f3] border-2 rounded mx-6 px-3 py-3 text-[14px] font-semibold no-underline"
>
Bekijk uitnodiging
</Button>
</Section>
<Section className="mt-4 mb-8 text-center">
<Link href={`${appBaseUrl}/farm`}>
of open {appName} om je uitnodigingen te
bekijken.
</Link>
</Section>
</>
)}
<Text className="text-black text-[14px] leading-6">
Als je deze uitnodiging niet wilt accepteren, kun je
deze e-mail negeren.
</Text>
<Text className="text-black text-[14px] leading-6 mt-8">
Met vriendelijke groet, <br /> Het {appName} team
</Text>
</Container>
</Body>
</Tailwind>
</Html>
)
}
16 changes: 6 additions & 10 deletions fdm-app/app/components/blocks/email/invitation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Body,
Button,
Container,
Font,
Head,
Heading,
Html,
Expand Down Expand Up @@ -34,20 +35,15 @@ export const InvitationEmail = ({
}: InvitationEmailProps) => {
const logoPath = `${appBaseUrl}${logoFileName}`

const fontFamily = `"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif`

return (
<Html lang="nl">
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
<Font
fontFamily="Inter"
fallbackFontFamily="sans-serif"
fontWeight={400}
fontStyle="normal"
/>
<style>{`
* {
font-family: ${fontFamily};
}
`}</style>
</Head>
<Preview>
{`${inviterName} heeft je uitgenodigd om lid te worden van ${organizationName} in ${appName}.`}
Expand Down
14 changes: 6 additions & 8 deletions fdm-app/app/components/blocks/email/magic-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Body,
Button,
Container,
Font,
Head,
Heading,
Html,
Expand Down Expand Up @@ -33,15 +34,12 @@ export const MagicLinkEmail = ({
<Html lang="nl">
<Head>
<title>{`Aanmelden bij ${appName}`}</title>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
<Font
fontFamily="Inter"
fallbackFontFamily="sans-serif"
fontWeight={400}
fontStyle="normal"
/>
<style>{`
* {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
`}</style>
</Head>
<Preview>{`Link om aan te melden bij ${appName}`}</Preview>
<Tailwind>
Expand Down
17 changes: 6 additions & 11 deletions fdm-app/app/components/blocks/email/welcome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Body,
Button,
Container,
Font,
Head,
Heading,
Hr,
Expand Down Expand Up @@ -30,23 +31,17 @@ export function WelcomeEmail({
}: WelcomeEmailProps) {
const previewText = `Welkom bij ${appName}! Krijg inzicht in je bedrijfsdata.`
const logoPath = `${appBaseUrl}${logoFileName}`

const fontFamily = `"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif`

const absoluteUrl = url.startsWith("http") ? url : `https://${url}`

return (
<Html lang="nl">
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
<Font
fontFamily="Inter"
fallbackFontFamily="sans-serif"
fontWeight={400}
fontStyle="normal"
/>
<style>{`
* {
font-family: ${fontFamily};
}
`}</style>
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
Expand Down
Loading