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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
},
"dependencies": {
"@profullstack/footer": "latest",
"@profullstack/profile": "0.1.3",
"@profullstack/referrals": "^0.1.0",
"@profullstack/stack": "^0.1.3",
"@profullstack/throttle": "^0.3.3",
Expand Down
89 changes: 89 additions & 0 deletions src/app/api/__tests__/signup-profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";

const getUserById = vi.fn();
const upsert = vi.fn();
const existing = vi.fn();

// A service-role supabase-js stand-in: auth.admin plus the user_profiles table.
vi.mock("@/lib/supabase/server", () => ({
createServiceClient: () => ({
auth: { admin: { getUserById } },
from: () => ({
select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: existing(), error: null }) }) }),
upsert: (row: Record<string, unknown>, opts: unknown) => {
upsert(row, opts);
return { select: () => ({ single: async () => ({ data: row, error: null }) }) };
},
}),
}),
}));

const fields = {
email: "Ada@Acme.com",
first_name: "Ada",
last_name: "Lovelace",
company_domain: "https://www.acme.com/about",
job_title: " VP Engineering ",
linkedin_url: "in/ada-lovelace",
};

function post(body: unknown) {
return new NextRequest("http://localhost/api/auth/signup-profile", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}

describe("POST /api/auth/signup-profile", () => {
beforeEach(() => {
vi.clearAllMocks();
existing.mockReturnValue(null);
});

it("refuses a signup without first and last name", async () => {
const { POST } = await import("@/app/api/auth/signup-profile/route");
const res = await POST(post({ email: "ada@acme.com" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(Object.keys(body.errors)).toEqual(expect.arrayContaining(["first_name", "last_name"]));
});

it("stores the six fields for the account signUp just created", async () => {
getUserById.mockResolvedValue({
data: { user: { id: "u1", email: "ada@acme.com", created_at: new Date().toISOString(), email_confirmed_at: null } },
error: null,
});
const { POST } = await import("@/app/api/auth/signup-profile/route");
const res = await POST(post({ ...fields, user_id: "u1" }));
expect(res.status).toBe(200);
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({
user_id: "u1",
email: "ada@acme.com",
first_name: "Ada",
last_name: "Lovelace",
company_domain: "acme.com",
job_title: "VP Engineering",
linkedin_url: "https://www.linkedin.com/in/ada-lovelace",
}),
{ onConflict: "user_id" },
);
});

it("will not write a confirmed or someone else's account", async () => {
getUserById.mockResolvedValue({
data: { user: { id: "u2", email: "ada@acme.com", created_at: new Date().toISOString(), email_confirmed_at: "2026-01-01" } },
error: null,
});
const { POST } = await import("@/app/api/auth/signup-profile/route");
expect((await POST(post({ ...fields, user_id: "u2" }))).status).toBe(403);
getUserById.mockResolvedValue({
data: { user: { id: "u3", email: "other@acme.com", created_at: new Date().toISOString(), email_confirmed_at: null } },
error: null,
});
expect((await POST(post({ ...fields, user_id: "u3" }))).status).toBe(403);
expect(upsert).not.toHaveBeenCalled();
});
});
63 changes: 63 additions & 0 deletions src/app/api/auth/signup-profile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { NextResponse, type NextRequest } from "next/server";
import { COLUMNS, validateProfile, saveSignupProfile } from "@profullstack/profile";
import { createServiceClient } from "@/lib/supabase/server";
import { profileStore } from "@/lib/profile";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// A just-created, still unconfirmed account may have its profile written once,
// without a session, for this long after signUp.
const FRESH_MS = 15 * 60 * 1000;

/**
* POST /api/auth/signup-profile: the @profullstack/profile fields on /register.
*
* Without user_id: validate only (called before supabase.auth.signUp so a
* missing name stops the signup). Returns { ok, profile } or 400 { errors }.
*
* With user_id (right after signUp, before the email is confirmed): store the
* fields for that new account. Accepted only for an unconfirmed account created
* in the last 15 minutes whose address matches and that has no profile yet, so
* nobody can write someone else's profile. The same fields also ride in user
* metadata and are saved on /auth/callback if this call never happens.
*/
export async function POST(request: NextRequest) {
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });

const input: Record<string, unknown> = Object.fromEntries(COLUMNS.map((c) => [c, body[c] ?? null]));
const check = validateProfile(input);
if (!check.ok) {
return NextResponse.json(
{ error: Object.values(check.errors)[0] ?? "Some fields need fixing.", errors: check.errors },
{ status: 400 },
);
}

const userId = typeof body.user_id === "string" ? body.user_id : "";
if (!userId) return NextResponse.json({ ok: true, profile: check.profile });

try {
const admin = createServiceClient();
const { data, error } = await admin.auth.admin.getUserById(userId);
const user = data?.user;
const fresh = user && Date.now() - new Date(user.created_at).getTime() < FRESH_MS;
if (
error ||
!user ||
!fresh ||
user.email_confirmed_at ||
(user.email ?? "").toLowerCase() !== check.profile.email
) {
return NextResponse.json({ error: "Not allowed" }, { status: 403 });
}
const store = profileStore();
if (await store.get(user.id)) return NextResponse.json({ ok: true, saved: false });
const saved = await saveSignupProfile(store, user.id, input, { email: user.email });
return NextResponse.json({ ok: saved.ok, saved: saved.ok });
} catch (e) {
console.error("[signup-profile] save failed", e);
return NextResponse.json({ error: "Could not save profile" }, { status: 500 });
}
}
22 changes: 22 additions & 0 deletions src/app/api/v1/me/profile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createProfileHandlers } from "@profullstack/profile";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { profileStore } from "@/lib/profile";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// GET / PUT / PATCH /api/v1/me/profile: the signed-in user's
// @profullstack/profile fields (session cookie).
const handlers = () =>
createProfileHandlers({
store: profileStore(),
getUser: async () => {
const supabase = await createServerSupabaseClient();
const { data } = await supabase.auth.getUser();
return data.user ? { id: data.user.id, email: data.user.email ?? null } : null;
},
});

export const GET = (req: Request) => handlers().GET(req);
export const PUT = (req: Request) => handlers().PUT(req);
export const PATCH = (req: Request) => handlers().PATCH(req);
3 changes: 3 additions & 0 deletions src/app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { getBrandSiteUrl } from "@/lib/brand-server";
import { safeNextPath } from "@/lib/safe-next";
import { saveProfileFromMetadata } from "@/lib/profile";

export { safeNextPath };

Expand Down Expand Up @@ -47,6 +48,8 @@ export async function GET(request: NextRequest) {
data: { user },
} = await supabase.auth.getUser();
signedIn = Boolean(user);
// First confirmation: store the /register profile fields (never blocks).
if (user) await saveProfileFromMetadata(user);
} catch (error) {
console.error("Auth callback error:", error);
signedIn = false;
Expand Down
8 changes: 8 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,11 @@
blocked slot leaves no hole. Low profile: one thin text_link line. */
.cp-ad { height: 0; overflow: hidden; }
.cp-ad[data-cp-filled] { height: auto; overflow: visible; margin: 0.5rem 0; }

/* @profullstack/profile fields: same look as the register/settings inputs. */
.pfs-profile { border: 0; margin: 0; padding: 0; min-width: 0; display: grid; gap: 1rem; }
.pfs-profile__row label { display: block; margin-bottom: 0.25rem; font-size: 0.875rem; font-weight: 500; color: var(--color-gray-300); }
.pfs-profile__row input { width: 100%; padding: 0.5rem 0.75rem; background: var(--color-gray-900); border: 1px solid var(--color-gray-700); border-radius: 0.5rem; }
.pfs-profile__row input:focus { outline: none; border-color: transparent; box-shadow: 0 0 0 2px var(--color-blue-500); }
.pfs-profile__optional { font-weight: 400; color: var(--color-gray-500); }
.pfs-profile__error { margin-top: 0.25rem; font-size: 0.875rem; color: var(--color-red-400); }
47 changes: 47 additions & 0 deletions src/app/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,20 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { Logo } from "@/components/logo";
import { nextFromLocation } from "@/lib/safe-next";
import { ProfileFields } from "@profullstack/profile/react";
import type { ProfileField } from "@profullstack/profile";

// Email has its own input; the shared @profullstack/profile component renders
// the other five (first/last name required, the rest optional).
const PROFILE_FIELDS: ProfileField[] = ["first_name", "last_name", "company_domain", "job_title", "linkedin_url"];
type ProfileValues = Partial<Record<ProfileField, string | null>>;

export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [profile, setProfile] = useState<ProfileValues>({});
const [fieldErrors, setFieldErrors] = useState<Partial<Record<ProfileField, string>>>({});
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [confirmSent, setConfirmSent] = useState(false);
Expand All @@ -28,6 +37,7 @@ export default function RegisterPage() {
e.preventDefault();
setLoading(true);
setError(null);
setFieldErrors({});

if (password !== confirmPassword) {
setError("Passwords do not match");
Expand All @@ -41,6 +51,23 @@ export default function RegisterPage() {
return;
}

// Validate the profile fields on the server before creating the account.
const fields = { ...Object.fromEntries(PROFILE_FIELDS.map((k) => [k, profile[k] ?? ""])), email };
const check = await fetch("/api/auth/signup-profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fields),
})
.then((r) => r.json())
.catch(() => null);
if (!check?.ok) {
setFieldErrors(check?.errors ?? {});
setError(check?.error ?? "Could not check your details. Try again.");
setLoading(false);
return;
}
const p = check.profile as ProfileValues;

const supabase = createClient();
const { data, error } = await supabase.auth.signUp({
email,
Expand All @@ -49,9 +76,21 @@ export default function RegisterPage() {
emailRedirectTo: `${window.location.origin}/auth/callback${
next === "/inbox" ? "" : `?next=${encodeURIComponent(next)}`
}`,
// Also saved on /auth/callback if the call below never lands.
data: Object.fromEntries(PROFILE_FIELDS.map((k) => [k, p[k] ?? null])),
},
});

// An already-registered address comes back with no identities: never
// write a profile for it. Best effort; the account exists either way.
if (!error && data.user && (data.user.identities?.length ?? 0) > 0) {
await fetch("/api/auth/signup-profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...fields, user_id: data.user.id }),
}).catch(() => null);
}

if (error) {
setError(error.message);
setLoading(false);
Expand Down Expand Up @@ -112,6 +151,14 @@ export default function RegisterPage() {
/>
</div>

<ProfileFields
fields={PROFILE_FIELDS}
idPrefix="register-"
values={profile}
onChange={setProfile}
errors={fieldErrors}
/>

<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-1">
Password
Expand Down
3 changes: 3 additions & 0 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useToast } from "@/contexts/toast-context";
import { ProfileCard } from "@/components/profile-card";
import {
ProviderTestPanel,
SETUP_GUIDES,
Expand Down Expand Up @@ -206,6 +207,8 @@ export default function SettingsPage() {
</Link>
</div>

<ProfileCard />

{/* Rented numbers */}
<section className="space-y-4">
<div className="bg-gray-900 rounded-lg p-4 border border-gray-800 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
Expand Down
Loading
Loading