Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1,854 changes: 1,741 additions & 113 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/app/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const isLocal = process.env.VERCEL_ENV === "local";
const nextConfig = {
env: {
NEXT_PUBLIC_API_URL: appUrl,
NEXT_PUBLIC_APP_URL: appUrl,
KINDE_SITE_URL: appUrl,
KINDE_POST_LOGOUT_REDIRECT_URL: `${appUrl}/login`,
KINDE_POST_LOGIN_REDIRECT_URL: `${appUrl}/projects`,
Expand Down
2 changes: 2 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.6",
"@react-email/components": "^0.5.0",
"@reduxjs/toolkit": "^2.3.0",
"@tanstack/react-query": "^5.66.3",
"@tanstack/react-query-devtools": "^5.66.9",
Expand Down Expand Up @@ -79,6 +80,7 @@
"react-day-picker": "^9.5.1",
"react-dom": "^18",
"react-dropzone": "^14.3.5",
"react-email": "^4.2.8",
"react-hook-form": "^7.53.0",
"react-icons": "^5.4.0",
"react-laag": "^2.0.5",
Expand Down
50 changes: 50 additions & 0 deletions packages/app/src/actions/projects/acceptInvitation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use server";

import { getServerUser } from "@/lib/auth";
import {
updateInvitationStatus,
createUserProject,
readInvitationForEmail,
} from "@/db/crud/project";
import { revalidatePath } from "next/cache";
import { revalidateUserProjectCache } from "@/lib/dal/projects";
import {
actionResult,
actionError,
runActionServer,
} from "@/lib/actions/utils";

export async function acceptInvitationAction(invitationId: string) {
return runActionServer(async () => {
const user = await getServerUser();

const invitation = await readInvitationForEmail(invitationId, user.email);
if (!invitation) {
return actionError("Invitation not found");
}

if (invitation.status !== "PENDING") {
return actionError("This invitation is no longer valid");
}

if (new Date() > invitation.expiresAt) {
return actionError("This invitation has expired");
}

// Update invitation status to accepted
await updateInvitationStatus(invitationId, "ACCEPTED", user.id);

// Add user to project
await createUserProject({
userId: user.id,
projectId: invitation.projectId,
role: invitation.role,
});

// Revalidate cache
revalidateUserProjectCache(invitation.projectId, user.id, user.authId);
revalidatePath(`/projects/${invitation.projectId}/team`);

return actionResult(undefined);
});
}
35 changes: 35 additions & 0 deletions packages/app/src/actions/projects/declineInvitation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use server";

import { getServerUser } from "@/lib/auth";
import {
readInvitationForEmail,
updateInvitationStatus,
} from "@/db/crud/project";
import {
actionResult,
actionError,
runActionServer,
} from "@/lib/actions/utils";

export async function declineInvitationAction(invitationId: string) {
return runActionServer(async () => {
const user = await getServerUser();

const invitation = await readInvitationForEmail(invitationId, user.email);
if (!invitation) {
return actionError("Invitation not found");
}

if (invitation.status !== "PENDING") {
return actionError("This invitation is no longer valid");
}

if (new Date() > invitation.expiresAt) {
return actionError("This invitation has expired");
}

await updateInvitationStatus(invitationId, "DECLINED");

return actionResult(undefined);
});
}
113 changes: 113 additions & 0 deletions packages/app/src/actions/projects/sendInvitation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"use server";

import { getServerUser } from "@/lib/auth";
import { readUserByEmail } from "@/db/crud/user";
import {
readUserProject,
readInvitationByEmailAndProject,
createProjectInvitation,
} from "@/db/crud/project";
import { revalidatePath } from "next/cache";
import { getProjectForUser } from "@/lib/dal/projects";
import { Resend } from "resend";
import { z } from "zod";
import { ProjectInvitationEmail } from "@/emails/project-invitation";
import {
actionResult,
actionError,
runActionServer,
} from "@/lib/actions/utils";

const resend = new Resend(process.env.RESEND_API_KEY);

const invitationSchema = z.object({
email: z.string().email("Invalid email address"),
role: z.enum(["OWNER", "CONTRIBUTOR", "VIEWER"]),
message: z.string().optional(),
});

export async function sendInvitationAction(
projectId: string,
data: z.infer<typeof invitationSchema>,
) {
return runActionServer(async () => {
// Validate the input data
const validatedData = invitationSchema.parse(data);

const callingUser = await getServerUser();
const callingUserProject = await getProjectForUser(callingUser, projectId);

if (!["OWNER", "CONTRIBUTOR"].includes(callingUserProject.role)) {
return actionError("Only owners and contributors can send invitations");
}

// Check if user is already a member
const existingUser = await readUserByEmail(validatedData.email);
if (existingUser) {
const existingMembership = await readUserProject(
projectId,
existingUser.id,
);
if (existingMembership) {
return actionError("User is already a member of this project");
}
}

// Check if there's already a pending invitation
const existingInvitation = await readInvitationByEmailAndProject(
validatedData.email,
projectId,
);
if (existingInvitation) {
return actionError("An invitation has already been sent to this email");
}

// Create invitation (expires in 7 days)
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);

const invitation = await createProjectInvitation({
projectId,
invitedByUserId: callingUser.id,
invitedUserEmail: validatedData.email,
role: validatedData.role,
message: validatedData.message,
expiresAt,
});

// Send invitation email
const project = callingUserProject.project;

// Create the invitation URL - for existing users, link to the invitation page
// For new users, link to signup with email pre-filled
const invitationUrl = existingUser
? `${process.env.NEXT_PUBLIC_APP_URL}/projects`
: `${process.env.NEXT_PUBLIC_APP_URL}/sign-up?email=${encodeURIComponent(validatedData.email)}`;

try {
await resend.emails.send({
from: "invitations@groundup.cloud",
to: validatedData.email,
subject: `You've been invited to join ${project.name} on GroundUp`,
react: ProjectInvitationEmail({
inviterName: `${callingUser.firstName} ${callingUser.lastName}`,
projectName: project.name,
projectDescription: project.description || undefined,
role: validatedData.role,
message: validatedData.message,
invitationUrl,
expiresAt,
}),
});
} catch (error) {
console.error("Failed to send invitation email:", error);
// Don't throw here - the invitation was created successfully
// The user can still access it via the direct link
}

revalidatePath(`/projects/${projectId}/team`);
revalidatePath(`/projects/${projectId}/invitations`);

return actionResult(invitation);
});
}
26 changes: 0 additions & 26 deletions packages/app/src/app/(app)/home/page.tsx

This file was deleted.

7 changes: 6 additions & 1 deletion packages/app/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import { getServerUser } from "@/lib/auth";

import { SidebarProvider } from "@/components/ui/sidebar";
import { Sidebar } from "@/components/common/sidebar";
import { Toaster } from "@/components/ui/sonner";
import { LoadingAuthScreen } from "@/components/auth/loading-auth";
import { Suspense } from "react";
import { readPendingInvitationsByEmailWithProjects } from "@/db/crud/project";

export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getServerUser();
const notificationsPromise = readPendingInvitationsByEmailWithProjects(
user.email,
);

return (
<SidebarProvider>
<Suspense fallback={<LoadingAuthScreen />}>
<Sidebar user={user} />
<Sidebar user={user} notificationsPromise={notificationsPromise} />
{children}
<Toaster />
</Suspense>
Expand Down
46 changes: 39 additions & 7 deletions packages/app/src/app/(auth)/sign-up/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useEffect, useState } from "react";
import { useSignUp } from "@clerk/nextjs";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
Expand Down Expand Up @@ -52,6 +52,7 @@ export default function SignUpPage() {
const [error, setError] = useState("");
const [consentAccepted, setConsentAccepted] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();

// Form for registration step
const registrationForm = useForm<RegistrationFormValues>({
Expand All @@ -73,6 +74,17 @@ export default function SignUpPage() {
},
});

// Handle email query parameter
useEffect(() => {
const emailParam = searchParams.get("email");
if (emailParam) {
// Pre-fill the email field
registrationForm.setValue("email", emailParam);
// Auto-navigate to registration form
setAuthView("registration_form");
}
}, [searchParams, registrationForm]);

// Reset error when changing views
useEffect(() => {
setError("");
Expand Down Expand Up @@ -217,13 +229,23 @@ export default function SignUpPage() {

// Show registration form
if (authView === "registration_form") {
const emailParam = searchParams.get("email");
const isFromInvitation = !!emailParam;

return (
<div className="space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">Create an account</h1>
<p className="text-muted-foreground">
Join GroundUp to manage your geotechnical data
</p>
{isFromInvitation ? (
<p className="text-muted-foreground">
You've been invited to join a project on GroundUp. Create your
account to get started.
</p>
) : (
<p className="text-muted-foreground">
Join GroundUp to manage your geotechnical data
</p>
)}
</div>

<Form {...registrationForm}>
Expand Down Expand Up @@ -375,13 +397,23 @@ export default function SignUpPage() {
}

// Main sign-up options view
const emailParam = searchParams.get("email");
const isFromInvitation = !!emailParam;

return (
<div className="space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">Create an account</h1>
<p className="text-muted-foreground">
Join GroundUp to manage your geotechnical data
</p>
{isFromInvitation ? (
<p className="text-muted-foreground">
You've been invited to join a project on GroundUp. Create your
account to get started.
</p>
) : (
<p className="text-muted-foreground">
Join GroundUp to manage your geotechnical data
</p>
)}
</div>

<div className="space-y-4">
Expand Down
Loading