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.

20 changes: 20 additions & 0 deletions db/schema.pg.sql
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ create table if not exists login_tokens (
);
create index if not exists i_login_email on login_tokens(email, created_at desc);
create index if not exists i_login_ip on login_tokens(ip, created_at desc);
-- The /signup form's fields (JSON, already through @profullstack/profile's
-- validateProfile). They ride on the link because the account is only made
-- when it is opened; saved to user_profiles then.
alter table login_tokens add column if not exists profile text;

create table if not exists credentials (
id text primary key,
Expand All @@ -62,6 +66,22 @@ create table if not exists follows (
);
create index if not exists i_follows_slug on follows(slug);

-- The signup profile (@profullstack/profile 0.1.3, `profile schema --key-type
-- bigint`), keyed by users.id. Server-only: no public page or API reads it.
create table if not exists "user_profiles" (
"user_id" bigint primary key,
"email" text,
"first_name" text,
"last_name" text,
"company_domain" text,
"job_title" text,
"linkedin_url" text,
"created_at" timestamptz not null default now(),
"updated_at" timestamptz not null default now()
);
create index if not exists "user_profiles_email" on "user_profiles" (lower("email"));
create index if not exists "user_profiles_company_domain" on "user_profiles" ("company_domain");

-- One row per refresh-pipeline run; what /crawlstats reads.
create table if not exists refresh_runs (
id bigint generated by default as identity primary key,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@profullstack/libsql-pg": "^0.1.2",
"@profullstack/openprofile": "^0.1.2",
"@profullstack/player": "0.15.0",
"@profullstack/profile": "0.1.3",
"@profullstack/scraper-daemon": "0.2.1",
"@profullstack/submit-feed": "^0.1.3",
"@profullstack/throttle": "^0.3.3",
Expand Down
21 changes: 20 additions & 1 deletion src/app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { count } from "@/lib/db";
import { currentUser } from "@/lib/auth/session";
import { credentialsFor } from "@/lib/auth/passkey";
import PasskeyManager from "@/components/PasskeyManager";
import ProfileForm from "@/components/ProfileForm";
import { COLUMNS } from "@profullstack/profile";
import { profileStore } from "@/lib/profile";
import Ad from "@/components/Ad";
import { AD_TEXT } from "@/lib/ads";

Expand All @@ -19,10 +22,17 @@ export default async function Account() {
const user = await currentUser();
if (!user) redirect("/login?next=/account");

const [passkeys, follows] = await Promise.all([
const [passkeys, follows, mine] = await Promise.all([
credentialsFor(user.id),
count("SELECT COUNT(*) n FROM follows WHERE user_id = ?", [user.id]),
// A missing table must not take the account page down with it.
profileStore().get(user.id).catch(() => null),
]);
// Only the six fields cross to the client form, as plain strings.
const profileValues: Record<string, string | null> = Object.fromEntries(
COLUMNS.map((c) => [c, mine?.[c] == null ? null : String(mine[c])]),
);
if (!profileValues.email) profileValues.email = user.email;

return (
<div className="wrap narrow">
Expand All @@ -42,6 +52,15 @@ export default async function Account() {
the reader came here to press. */}
<Ad format={AD_TEXT} />

<section className="panel">
<h2>Your details</h2>
<p className="muted">
Your name and, if you like, where you work. Only the email and your name are
required.
</p>
<ProfileForm initial={profileValues} />
</section>

<section className="panel">
<h2>Following</h2>
<p>
Expand Down
16 changes: 14 additions & 2 deletions src/app/api/auth/magic/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { headers } from "next/headers";
import { normaliseEmail, pruneLinks, requestLink } from "@/lib/auth/magic";
import { origin } from "@/lib/auth/session";
import { safeNext } from "@/lib/auth/next";
import { signupProfileFrom } from "@/lib/auth/signupProfile";

export const dynamic = "force-dynamic";

Expand All @@ -20,21 +21,32 @@ export async function POST(req: Request) {
} catch {
return Response.json(SAME_ANSWER);
}
const { email: rawEmail, next: rawNext } = (body ?? {}) as Record<string, unknown>;
const fields = (body ?? {}) as Record<string, unknown>;
const { email: rawEmail, next: rawNext } = fields;
const email = normaliseEmail(rawEmail);
if (!email) {
return Response.json(
{ ok: false, message: "That does not look like an email address." },
{ status: 400 },
);
}
// /signup sends its profile fields with `signup: true`; they are checked
// before any link is minted, so a refused form never mails anybody. Saying
// a name is missing reveals nothing about who has an account.
const signup = signupProfileFrom(fields, email);
if (!signup.ok) {
return Response.json(
{ ok: false, message: "A few of those fields need another look.", errors: signup.errors },
{ status: 400 },
);
}

const h = await headers();
const ip =
h.get("x-forwarded-for")?.split(",")[0].trim() || h.get("x-real-ip") || null;

try {
await requestLink(email, await origin(), safeNext(rawNext), ip);
await requestLink(email, await origin(), safeNext(rawNext), ip, signup.pending);
await pruneLinks();
} catch (err) {
// A Resend outage must not become an account oracle either.
Expand Down
27 changes: 27 additions & 0 deletions src/app/api/v1/me/profile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createProfileHandlers } from "@profullstack/profile";
import { currentUser } from "@/lib/auth/session";
import { profileStore } from "@/lib/profile";

export const dynamic = "force-dynamic";

/**
* The signed-in listener's signup profile: GET it, PUT a whole one, PATCH
* some fields. Session cookie only; anybody else gets a 401. Built on first
* request so the build never touches the database.
*/
let handlers: ReturnType<typeof createProfileHandlers> | null = null;

function profileHandlers() {
handlers ??= createProfileHandlers({
store: profileStore(),
getUser: async () => {
const user = await currentUser();
return user ? { id: String(user.id), email: user.email } : null;
},
});
return handlers;
}

export const GET = (req: Request) => profileHandlers().GET(req);
export const PUT = (req: Request) => profileHandlers().PUT(req);
export const PATCH = (req: Request) => profileHandlers().PATCH(req);
11 changes: 8 additions & 3 deletions src/app/auth/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { redirect } from "next/navigation";
import { consumeLink } from "@/lib/auth/magic";
import { findOrCreateUser, startSession } from "@/lib/auth/session";
import { safeNext } from "@/lib/auth/next";
import { saveVerifiedProfile } from "@/lib/auth/signupProfile";
import { profileStore } from "@/lib/profile";

export const dynamic = "force-dynamic";

Expand All @@ -10,12 +12,15 @@ export async function GET(req: Request) {
const token = params.get("token") || "";
const next = safeNext(params.get("next"));

const email = token ? await consumeLink(token) : null;
if (!email) redirect("/login?expired=1");
const link = token ? await consumeLink(token) : null;
if (!link) redirect("/login?expired=1");

// An unknown address makes the account here. There is no separate sign-up
// flow to keep in step — opening the link IS the registration.
const user = await findOrCreateUser(email);
const user = await findOrCreateUser(link.email);
// A /signup link carries the form's name and details; saved now the account
// exists. Never blocks the sign-in.
await saveVerifiedProfile(profileStore(), user, link.profile);
await startSession(user.id);
redirect(next || "/following");
}
17 changes: 17 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,23 @@ button.btn:disabled { opacity: .55; cursor: default; }
.auth-swap { margin-top: 28px; font-size: 14.5px; color: var(--muted); }
.auth-swap a { color: var(--accent); text-decoration: underline; }
.auth-done { padding: 60px 0; }

/* The signup profile from @profullstack/profile (/signup, /account): labelled
rows that match the email box above them. */
.pfs-profile { border: 0; padding: 0; margin: 0 0 4px; min-width: 0; }
.pfs-profile__row label {
display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.pfs-profile__optional { opacity: .8; }
.pfs-profile input[type="text"], .pfs-profile input[type="url"], .pfs-profile input[type="email"] {
width: 100%; padding: 12px 14px; font-size: 16px; font-family: inherit;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); color: var(--text); margin-bottom: 12px;
}
.pfs-profile input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.pfs-profile input[aria-invalid="true"] { border-color: var(--accent); }
.pfs-profile__error { color: var(--accent); font-size: 13.5px; margin: -6px 0 12px; }
.auth-done h2 { font-size: 26px; margin: 0 0 12px; letter-spacing: -0.02em; }

.panel {
Expand Down
27 changes: 24 additions & 3 deletions src/components/AuthForm.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { useState } from "react";
import { useRef, useState } from "react";
import { startAuthentication } from "@simplewebauthn/browser";
import { ProfileFields } from "@profullstack/profile/react";
import { suggestEmail } from "@/lib/auth/typo";

/**
Expand All @@ -28,6 +29,8 @@ export default function AuthForm({
);
const [error, setError] = useState<string | null>(null);
const [suggestion, setSuggestion] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const formRef = useRef<HTMLFormElement>(null);

/**
* A near-miss like `.con` is checked here because it cannot be checked
Expand All @@ -51,16 +54,24 @@ export default function AuthForm({
async function send(address: string) {
setBusy(true);
setError(null);
// /signup also sends the profile fields; /login only ever sends the address.
const profile: Record<string, unknown> = {};
if (mode === "signup" && formRef.current) {
for (const [k, v] of new FormData(formRef.current)) if (k !== "email") profile[k] = v;
profile.signup = true;
}
try {
const res = await fetch("/api/auth/magic", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: address, next }),
body: JSON.stringify({ ...profile, email: address, next }),
});
const body = await res.json();
if (!res.ok) {
setFieldErrors(body.errors || {});
setError(body.message || "That did not work. Try again.");
} else {
setFieldErrors({});
setSent(true);
setSentTo(address);
setNote(body.message);
Expand Down Expand Up @@ -146,7 +157,7 @@ export default function AuthForm({

{note && !sent && <p className="auth-note">{note}</p>}

<form onSubmit={submit}>
<form ref={formRef} onSubmit={submit}>
<label htmlFor="email">Email address</label>
<input
id="email"
Expand Down Expand Up @@ -181,6 +192,16 @@ export default function AuthForm({
</p>
)}

{/* /signup only: the profile a new account starts with. Kept mounted
(uncontrolled) so a refused submit keeps what was typed. */}
{mode === "signup" && (
<ProfileFields
fields={["first_name", "last_name", "company_domain", "job_title", "linkedin_url"]}
errors={fieldErrors}
idPrefix="signup-"
/>
)}

<button className="btn primary wide" type="submit" disabled={busy || !email}>
{busy ? "Sending…" : "Email me a link"}
</button>
Expand Down
55 changes: 55 additions & 0 deletions src/components/ProfileForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use client";

import { useState } from "react";
import { ProfileFields } from "@profullstack/profile/react";

type Values = Record<string, string | null>;

/**
* The signup profile, editable on /account: prefilled from the server, saved
* with PATCH /api/v1/me/profile. Uncontrolled, re-keyed on save so the inputs
* show what the server normalized them to.
*/
export default function ProfileForm({ initial }: { initial: Values }) {
const [values, setValues] = useState<Values>(initial);
const [errors, setErrors] = useState<Record<string, string>>({});
const [status, setStatus] = useState("");
const [busy, setBusy] = useState(false);

async function save(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setBusy(true);
setStatus("");
try {
const body = Object.fromEntries(new FormData(e.currentTarget).entries());
const res = await fetch("/api/v1/me/profile", {
method: "PATCH",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
setErrors({});
setValues(data.profile ?? data);
setStatus("Saved.");
} else {
setErrors(data.errors ?? {});
setStatus(data.errors ? "A few of those fields need another look." : "Could not save. Try again.");
}
} catch {
setStatus("Could not save. Try again.");
} finally {
setBusy(false);
}
}

return (
<form onSubmit={save}>
<ProfileFields key={JSON.stringify(values)} defaultValues={values} errors={errors} idPrefix="account-" />
<button className="btn" type="submit" disabled={busy}>
{busy ? "Saving…" : "Save details"}
</button>
{status && <span className="muted small"> {status}</span>}
</form>
);
}
Loading
Loading