-
-
Notifications
You must be signed in to change notification settings - Fork 2
Feat: Integrate Auth (supabase) + UI user pages #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 58 commits
e3eb38c
f1a05c3
7e6030a
9d0fd70
4a81b33
2b08271
d5393af
40e2ff4
c6363f7
cb23602
e88a273
3e4a65c
6857a94
4fdcaf6
c6bcb4f
2bdb1e3
80bd37d
c339ad4
8d0e7c2
2af814c
c788a05
17ef619
ad64840
834ba74
cc9f366
9d4f148
403798b
1b5b179
f92579d
f12afcc
55ccb5c
587f7c8
97262a4
105b831
767e442
f45ad50
c4280b2
4cea79c
7eb356c
7aea44b
78dabd4
3b1b4d2
1ef59eb
e9031a7
5ab6549
3ffcd52
0cec3e6
34b163f
fd77dbb
ab1ff30
da13352
988e7c0
5bf4c38
38cf584
a63f178
4a682de
f79204d
df73c6b
3dd0ca3
2bccec8
361a567
685ade3
985e546
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,12 @@ | ||
| # For turbo builds, prefixes with NEXT_PUBLIC_ are automatically considered to force a rebuild | ||
| NEXT_PUBLIC_POSTHOG_KEY= | ||
| NEXT_PUBLIC_POSTHOG_HOST= | ||
| NEXT_PUBLIC_POSTHOG_HOST= | ||
| NEXT_PUBLIC_SUPABASE_URL= | ||
| # Make sure to enable RLS and policies to use this key in client | ||
| NEXT_PUBLIC_SUPABASE_ANON_KEY= | ||
| SUPABASE_SERVICE_ROLE_KEY= | ||
| SUPABASE_URL= | ||
| PLUNK_API_KEY= | ||
| CONTACT_FROM_EMAIL= | ||
| CONTACT_TO_EMAIL= | ||
| ARCJET_KEY= |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| "use server"; | ||
|
|
||
| import { emailService } from "@/lib/email"; | ||
| import { createClient } from "@/lib/supabase/server"; | ||
| import arcjet, { request, slidingWindow } from "@arcjet/next"; | ||
|
|
||
| // Per user rate limiter: 3 requests per day per user. | ||
| // This is to prevent abuse of the contact form. | ||
| const rateLimiter = arcjet({ | ||
| key: process.env.ARCJET_KEY!, | ||
| rules: [ | ||
| slidingWindow({ | ||
| mode: "LIVE", // will block requests. Use "DRY_RUN" to log only | ||
| characteristics: ["userId"], | ||
| interval: "24h", // 24 hour sliding window | ||
| max: 3, // allow a maximum of 3 requests per day per IP address | ||
| }), | ||
| ], | ||
| }); | ||
alexmarqs marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| export const sendContactMessageAction = async (formData: FormData) => { | ||
| const supabase = await createClient(); | ||
|
|
||
| // Get the authenticated user's email from the server session | ||
| const { | ||
| data: { user }, | ||
| error, | ||
| } = await supabase.auth.getUser(); | ||
|
|
||
| if (error || !user?.email) { | ||
| throw new Error("User not authenticated"); | ||
| } | ||
|
|
||
| const req = await request(); | ||
| const decision = await rateLimiter.protect(req, { userId: user.id }); | ||
|
|
||
| if (decision.isDenied()) | ||
| throw new Error("Rate limit exceeded. Please try again later."); | ||
|
|
||
| const message = formData.get("message")?.toString().trim(); | ||
|
|
||
| if (!message || message.length === 0) { | ||
| throw new Error("Message is required"); | ||
| } | ||
|
|
||
| const fromEmail = process.env.CONTACT_FROM_EMAIL; | ||
| const toEmail = process.env.CONTACT_TO_EMAIL; | ||
|
|
||
| if (!fromEmail || !toEmail) { | ||
| throw new Error("Email configuration is missing"); | ||
| } | ||
|
|
||
| const firstPartEmail = user.email.split("@")[0]; | ||
| const fullName = user.user_metadata?.full_name; | ||
|
|
||
| await emailService.sendEmail({ | ||
| from: fromEmail, | ||
| name: "Tech Companies Portugal", | ||
| subject: "New message received — Tech Companies Portugal", | ||
| body: ` | ||
| <p>Hello 👋</p> | ||
| <p>You have received a new message through the <strong>Tech Companies Portugal</strong> website.</p> | ||
|
|
||
| <p><strong>From:</strong> ${firstPartEmail}${fullName ? ` (${fullName})` : ""}</p> | ||
|
|
||
| <p><strong>Message:</strong></p> | ||
| <blockquote style="border-left:3px solid #ccc;padding-left:8px;margin:10px 0;"> | ||
| <p>${message}</p> | ||
| </blockquote> | ||
|
|
||
| <hr> | ||
| <p style="font-size:12px;color:#666;"> | ||
| Automated message from Tech Companies Portugal — ${new Date().toLocaleDateString()}. | ||
| </p> | ||
| `, | ||
| to: toEmail, | ||
| }); | ||
alexmarqs marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return { success: true }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import WelcomeEmail from "@/emails/templates/welcome"; | ||
| import { emailService } from "@/lib/email"; | ||
| import { createClient } from "@/lib/supabase/server"; | ||
| import { render } from "@react-email/components"; | ||
| import { waitUntil } from "@vercel/functions"; | ||
| import { differenceInSeconds } from "date-fns"; | ||
| import { NextResponse } from "next/server"; | ||
|
|
||
| export async function GET(request: Request) { | ||
| // TODO: Rate limit | ||
|
|
||
| const { searchParams, origin } = new URL(request.url); | ||
| const code = searchParams.get("code"); | ||
| // if "next" is in params, use it as the redirect URL | ||
| const next = searchParams.get("next") ?? "/"; | ||
|
|
||
alexmarqs marked this conversation as resolved.
Show resolved
Hide resolved
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if (code) { | ||
| const supabase = await createClient(); | ||
| const { error, data } = await supabase.auth.exchangeCodeForSession(code); | ||
|
|
||
| if (!error) { | ||
| const forwardedHost = request.headers.get("x-forwarded-host"); | ||
| const isLocalEnv = process.env.NODE_ENV === "development"; | ||
alexmarqs marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
alexmarqs marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // if the user was created in the last 15 seconds, send the welcome email | ||
|
Comment on lines
+20
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t trust X-Forwarded-Host blindly; restrict to an allowlist. Attackers can send a forged header to force redirects off-site. Parse the first value and only honor it if it matches your known host; otherwise fall back to origin. Apply: - const forwardedHost = request.headers.get("x-forwarded-host");
+ // use first value only; some proxies send comma-separated list
+ const forwardedHostHeader =
+ request.headers.get("x-forwarded-host") || "";
+ const forwardedHost = forwardedHostHeader.split(",")[0].trim();
+ const allowedHost = new URL(
+ process.env.NEXT_PUBLIC_APP_URL || origin,
+ ).host;- if (forwardedHost) {
+ if (forwardedHost && forwardedHost === allowedHost) {
// in case we user load balancer or proxy, we need to redirect to the correct host
- return NextResponse.redirect(`https://${forwardedHost}${next}`);
+ return NextResponse.redirect(`https://${forwardedHost}${sanitizedNext}`);
}Also applies to: 46-49 🤖 Prompt for AI Agents |
||
| if ( | ||
| data?.session?.user.created_at && | ||
| differenceInSeconds( | ||
| new Date(), | ||
| new Date(data.session.user.created_at), | ||
| ) < 15 && | ||
| data?.session?.user.email | ||
| ) { | ||
| // this will be run in the background | ||
| waitUntil( | ||
| sendWelcomeEmail( | ||
| data?.session?.user.email, | ||
| data?.session?.user.user_metadata.full_name, | ||
| ), | ||
alexmarqs marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ); | ||
alexmarqs marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| if (isLocalEnv) { | ||
| // we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host | ||
| return NextResponse.redirect(`${origin}${next}`); | ||
| } | ||
|
|
||
| if (forwardedHost) { | ||
| // in case we user load balancer or proxy, we need to redirect to the correct host | ||
| return NextResponse.redirect(`https://${forwardedHost}${next}`); | ||
| } | ||
|
|
||
| return NextResponse.redirect(`${origin}${next}`); | ||
| } | ||
| } | ||
|
|
||
| // if there is an error or no code, redirect to the auth code error page with possible instructions | ||
| return NextResponse.redirect(`${origin}/auth/auth-code-error`); | ||
| } | ||
|
|
||
| const sendWelcomeEmail = async (email: string, name = "there") => { | ||
| const emailHtml = await render( | ||
| WelcomeEmail({ | ||
| userFirstname: name, | ||
| }), | ||
| ); | ||
|
|
||
| await emailService.sendEmail({ | ||
| to: email, | ||
| subject: "Welcome to Tech Companies Portugal", | ||
| body: emailHtml, | ||
| }); | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.