Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
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
4 changes: 1 addition & 3 deletions apps/web/client/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ import path from 'node:path';
import './src/env';

const nextConfig: NextConfig = {
devIndicators: {
buildActivity: false,
},
devIndicators: false,
eslint: {
ignoreDuringBuilds: true,
},
Expand Down
1 change: 0 additions & 1 deletion apps/web/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@
"clsx": "^2.1.1",
"culori": "^4.0.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"embla-carousel-react": "^8.6.0",
"flexsearch": "^0.8.160",
"freestyle-sandboxes": "^0.0.78",
Expand Down
48 changes: 41 additions & 7 deletions apps/web/client/src/app/invitation/[id]/_components/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ export function Main({ invitationId }: { invitationId: string }) {
const { data: invitation, isLoading: loadingInvitation } = api.invitation.get.useQuery({
id: invitationId,
});
const acceptInvitationMutation = api.invitation.accept.useMutation({
const { mutate: acceptInvitation, isPending: isAcceptingInvitation, error: acceptInvitationError } = api.invitation.accept.useMutation({
onSuccess: () => {
router.push(Routes.PROJECTS);
if (invitation?.projectId) {
router.push(`${Routes.PROJECTS}/${invitation.projectId}`);
} else {
router.push(Routes.PROJECTS);
}
},
});

Expand All @@ -34,12 +38,42 @@ export function Main({ invitationId }: { invitationId: string }) {
);
}

if (acceptInvitationError) {
return (
<div className="flex flex-row w-full">
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<div className="flex items-center gap-4">
<Icons.ExclamationTriangle className="h-6 w-6" />
<div className="text-2xl">Error accepting invitation</div>
</div>
<div className="text-md">
{acceptInvitationError.message}
</div>
<div className="flex justify-center">
<Button
type="button"
onClick={() => {
router.push(Routes.PROJECTS);
}}
>
<Icons.ArrowLeft className="h-4 w-4" />
Back to home
</Button>
</div>
</div>
</div>
);
}

if (!invitation || !token) {
return (
<div className="flex flex-row w-full">
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<div className="text-xl text-foreground-secondary">Invitation not found</div>
<div className="text-md text-foreground-tertiary">
<div className="flex items-center gap-4">
<Icons.ExclamationTriangle className="h-6 w-6" />
<div className="text-xl">Invitation not found</div>
</div>
<div className="text-md">
The invitation you are looking for does not exist or has expired.
</div>
<div className="flex justify-center">
Expand Down Expand Up @@ -69,14 +103,14 @@ export function Main({ invitationId }: { invitationId: string }) {
<Button
type="button"
onClick={() => {
acceptInvitationMutation.mutate({
acceptInvitation({
id: invitationId,
token: invitation.token,
});
}}
disabled={acceptInvitationMutation.isPending}
disabled={isAcceptingInvitation}
>
Join Project
Accept Invitation
</Button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const Members = () => {
<Icons.Plus className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-96" align="end">
<PopoverContent className="p-0 w-96" >
<MembersContent />
</PopoverContent>
</Popover>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ export const InviteMemberInput = ({ projectId }: { projectId: string }) => {
const apiUtils = api.useUtils();
const [email, setEmail] = useState('');
const [selectedRole, setSelectedRole] = useState<ProjectRole>(ProjectRole.ADMIN);
const [isLoading, setIsLoading] = useState(false);

const createInvitationMutation = api.invitation.create.useMutation({
const createInvitation = api.invitation.create.useMutation({
onSuccess: () => {
apiUtils.invitation.list.invalidate();
apiUtils.invitation.suggested.invalidate();
Expand All @@ -22,13 +23,15 @@ export const InviteMemberInput = ({ projectId }: { projectId: string }) => {
},
});

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
setIsLoading(true);
e.preventDefault();
createInvitationMutation.mutate({
await createInvitation.mutateAsync({
inviteeEmail: email,
role: selectedRole,
projectId: projectId,
});
setIsLoading(false);
};

return (
Expand Down Expand Up @@ -59,7 +62,7 @@ export const InviteMemberInput = ({ projectId }: { projectId: string }) => {
</SelectContent>
</Select> */}
</div>
<Button type="submit" disabled={!email}>
<Button type="submit" disabled={!email || isLoading}>
Invite
</Button>
</form>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEditorEngine } from '@/components/store/editor';
import { api } from '@/trpc/react';
import { Icons } from '@onlook/ui/icons/index';
import { InvitationRow } from './invitation-row';
import { InviteMemberInput } from './invite-member-input';
import { MemberRow } from './member-row';
Expand All @@ -16,7 +17,10 @@ export const MembersContent = () => {
});

if (loadingMembers && loadingInvitations) {
return <div className="p-3 text-muted-foreground text-sm">Loading...</div>;
return <div className="h-32 gap-2 p-3 text-muted-foreground text-sm flex items-center justify-center">
<Icons.LoadingSpinner className="h-6 w-6 animate-spin text-foreground-primary" />
<div className="text-sm">Loading members...</div>
</div>;
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,8 @@ export const TopBar = observer(() => {
</div>
<ModeToggle />
<div className="flex flex-grow basis-0 justify-end items-center gap-2 mr-2">
{isEnabled('NEXT_PUBLIC_FEATURE_COLLABORATION') && (
<Members />
)}
<Members />
<CurrentUserAvatar className="size-7 cursor-pointer hover:opacity-80" />
<motion.div
className="space-x-0 hidden lg:block"
layout
Expand Down Expand Up @@ -102,7 +101,6 @@ export const TopBar = observer(() => {
</TooltipContent>
</Tooltip>
<PublishButton />
<CurrentUserAvatar className="size-8 cursor-pointer hover:opacity-80" />
</div>
</div>
);
Expand Down
42 changes: 42 additions & 0 deletions apps/web/client/src/app/project/[id]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { api } from "@/trpc/server";
import { Routes } from "@/utils/constants";
import { Icons } from "@onlook/ui/icons/index";
import Link from "next/link";

export default async function Layout({ params, children }: Readonly<{ params: Promise<{ id: string }>, children: React.ReactNode }>) {
const projectId = (await params).id;
const hasAccess = await api.project.hasAccess({ projectId });
if (!hasAccess) {
return <NoAccess />;
}
return <>{children}</>;
}

const NoAccess = () => {
const SUPPORT_EMAIL = '[email protected]';
return (
<main className="flex flex-1 flex-col items-center justify-center h-screen w-screen p-4 text-center">
<div className="space-y-6">
<div className="space-y-2">
<h1 className="text-4xl font-bold tracking-tight text-foreground-primary">Access denied</h1>
<h2 className="text-2xl font-semibold tracking-tight text-foreground-primary">{`Please contact the project owner to request access.`}</h2>
<p className="text-foreground-secondary">
{`Please email `}
<Link href={`mailto:${SUPPORT_EMAIL}`} className="text-primary underline">
{SUPPORT_EMAIL}
</Link>
{` if you believe this is an error.`}
</p>
</div>

<Link
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
href={Routes.PROJECTS}
>
<Icons.ArrowLeft className="h-4 w-4" />
Back to projects
</Link>
</div>
</main>
);
};
45 changes: 34 additions & 11 deletions apps/web/client/src/server/api/routers/project/invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { getResendClient, sendInvitationEmail } from '@onlook/email';
import { ProjectRole } from '@onlook/models';
import { isFreeEmail } from '@onlook/utility';
import { TRPCError } from '@trpc/server';
import dayjs from 'dayjs';
import { addDays, isAfter } from 'date-fns';
import { and, eq, ilike, isNull } from 'drizzle-orm';
import urlJoin from 'url-join';
import { v4 as uuidv4 } from 'uuid';
Expand Down Expand Up @@ -77,8 +77,18 @@ export const invitationRouter = createTRPCRouter({
message: 'You must be logged in to invite a user',
});
}
const inviter = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.user.id),
});

if (!inviter) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Inviter not found',
});
}

const invitation = await ctx.db
const [invitation] = await ctx.db
.transaction(async (tx) => {
const existingUser = await tx
.select()
Expand Down Expand Up @@ -107,12 +117,11 @@ export const invitationRouter = createTRPCRouter({
role: input.role as ProjectRole,
token: uuidv4(),
inviterId: ctx.user.id,
expiresAt: dayjs().add(7, 'day').toDate(),
expiresAt: addDays(new Date(), 7),
},
])
.returning();
})
.then(([invitation]) => invitation);

if (invitation) {
if (!env.RESEND_API_KEY) {
Expand All @@ -125,19 +134,20 @@ export const invitationRouter = createTRPCRouter({
apiKey: env.RESEND_API_KEY,
});

await sendInvitationEmail(
const result = await sendInvitationEmail(
emailClient,
{
inviteeEmail: input.inviteeEmail,
invitedByName: inviter.firstName ?? inviter.displayName ?? undefined,
invitedByEmail: ctx.user.email,
inviteLink: urlJoin(
env.NEXT_PUBLIC_SITE_URL,
'invitation',
invitation.id,
new URLSearchParams([['token', invitation.token]]).toString(),
`${invitation.id}?token=${invitation.token}`,
),
},
{
dryRun: process.env.NODE_ENV !== 'production',
// dryRun: process.env.NODE_ENV !== 'production',
},
);
}
Expand Down Expand Up @@ -165,7 +175,6 @@ export const invitationRouter = createTRPCRouter({
where: and(
eq(projectInvitations.id, input.id),
eq(projectInvitations.token, input.token),
eq(projectInvitations.inviteeEmail, ctx.user.email),
),
with: {
project: {
Expand All @@ -176,7 +185,21 @@ export const invitationRouter = createTRPCRouter({
},
});

if (!invitation || dayjs().isAfter(dayjs(invitation.expiresAt))) {
if (invitation?.inviteeEmail !== ctx.user.email) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'You are not the invitee of this invitation',
});
}

if (!invitation) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invitation does not exist',
});
}

if (isAfter(new Date(), invitation.expiresAt)) {
if (invitation) {
await ctx.db
.delete(projectInvitations)
Expand All @@ -185,7 +208,7 @@ export const invitationRouter = createTRPCRouter({

throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invitation does not exist or has expired',
message: 'Invitation has expired',
});
}

Expand Down
14 changes: 14 additions & 0 deletions apps/web/client/src/server/api/routers/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ import { createTRPCRouter, protectedProcedure } from '../../trpc';
import { projectCreateRequestRouter } from './createRequest';

export const projectRouter = createTRPCRouter({
hasAccess: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const user = ctx.user;
const project = await ctx.db.query.projects.findFirst({
where: eq(projects.id, input.projectId),
with: {
userProjects: {
where: eq(userProjects.userId, user.id),
},
},
});
return !!project && project.userProjects.length > 0;
}),
createRequest: projectCreateRequestRouter,
captureScreenshot: protectedProcedure
.input(z.object({ projectId: z.string() }))
Expand Down
15 changes: 9 additions & 6 deletions packages/email/src/invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ import { type InviteUserEmailProps, InviteUserEmail } from './templates';
import type { SendEmailParams } from './types/send-email';

export const sendInvitationEmail = async (...params: SendEmailParams<InviteUserEmailProps>) => {
const [client, { invitedByEmail, inviteLink }, { dryRun = false } = {}] = params;
const [client, inviteParams, { dryRun = false } = {}] = params;
const { inviteeEmail, invitedByEmail, inviteLink, invitedByName } = inviteParams;

if (dryRun) {
const rendered = await render(InviteUserEmail({ invitedByEmail, inviteLink }));
const rendered = await render(
InviteUserEmail({ inviteeEmail, invitedByEmail, inviteLink }),
);
console.log(rendered);
return;
}

return await client.emails.send({
from: 'Onlook <onlook@onlook.dev>',
to: invitedByEmail,
subject: 'You have been invited to Onlook',
react: InviteUserEmail({ invitedByEmail, inviteLink }),
from: 'Onlook <support@onlook.com>',
to: inviteeEmail,
subject: `Join ${invitedByName ?? invitedByEmail} on Onlook`,
react: InviteUserEmail(inviteParams),
});
};
Loading