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 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dependencies": {
"@profullstack/footer": "latest",
"@profullstack/emailer": "^1.0.3",
"@profullstack/profile": "0.1.2",
"@profullstack/throttle": "^0.3.3",
"@profullstack/x402-gateway": "^0.11.0",
"@r4ck/auth": "workspace:*",
Expand Down
29 changes: 29 additions & 0 deletions apps/web/public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ button.button {
input[type="search"],
input[type="text"],
input[type="email"],
input[type="url"],
select {
height: var(--control-h);
border-radius: 10px;
Expand Down Expand Up @@ -495,6 +496,34 @@ label {
gap: 0.75rem;
margin-bottom: 1rem;
}
/* The shared profile fields (@profullstack/profile renderProfileFields): a bare
fieldset of label + input rows, made to read like the email box above them. */
.pfs-profile {
display: grid;
gap: 0.75rem;
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
.pfs-profile__row {
display: grid;
gap: 0.35rem;
}
.pfs-profile__row label {
font-size: 0.9rem;
font-weight: 500;
}
.pfs-profile__optional {
color: var(--muted);
font-weight: 400;
font-size: 0.8rem;
}
.pfs-profile__error {
color: var(--err);
font-size: 0.85rem;
margin: 0;
}
.inline-form {
display: flex;
gap: 0.5rem;
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/lib/profile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createProfileStore } from '@profullstack/profile';
import { sql } from '@r4ck/db';

/**
* The shared Profullstack profile (@profullstack/profile): email, names, company
* domain, job title and LinkedIn, in the user_profiles table keyed by users.id
* (migration 0002). Private to the account; never shown to anyone else.
*/
export const profiles = createProfileStore(sql);

/** The session user as the package's handlers want it: `{ id, email }` or null. */
export const profileUser = (c) => {
const user = c.get('user');
return user ? { id: user.id, email: user.email } : null;
};
7 changes: 6 additions & 1 deletion apps/web/src/routes/api.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { mountProfileRoutes } from '@profullstack/profile';
import * as auth from '@r4ck/auth';
import { config } from '@r4ck/config';
import { KINDS, SORTS } from '@r4ck/core/facets';
import { currentDaemon, openserverOrigins } from '@r4ck/sync/daemon';
import { meterApi, pricing } from '../lib/gate.js';
import { Denied, requireUser } from '../lib/http.js';
import { profiles, profileUser } from '../lib/profile.js';
import * as svc from '../lib/service.js';

const CORS = {
'access-control-allow-origin': '*',
'access-control-allow-headers': 'content-type, authorization, x-api-key, x-crawl-pass',
'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS',
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'access-control-expose-headers':
'x-plan, x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset',
};
Expand Down Expand Up @@ -64,6 +66,7 @@ export function registerApi(app) {
'/api/v1/deals',
'/api/v1/stats',
'/api/v1/me',
'/api/v1/me/profile',
'/api/v1/keys',
'/api/v1/saved',
],
Expand Down Expand Up @@ -180,6 +183,8 @@ export function registerApi(app) {
saved: (await svc.listSaved(user)).saved,
});
});
// The shared @profullstack/profile row: GET/PUT/PATCH, the package's own handlers.
mountProfileRoutes(app, { store: profiles, getUser: (_req, c) => profileUser(c) });
app.get('/api/v1/keys', async (c) =>
c.json({ keys: (await auth.listApiKeys(requireUser(c).id)).map(keyOut) }),
);
Expand Down
39 changes: 36 additions & 3 deletions apps/web/src/routes/auth.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { saveSignupProfile, validateProfile } from '@profullstack/profile';
import * as auth from '@r4ck/auth';
import { config } from '@r4ck/config';
import * as accounts from '@r4ck/db/accounts';
import { getCookie, setCookie } from 'hono/cookie';
import { render, requireUser, respond } from '../lib/http.js';
import { sendLoginLink } from '../lib/mail.js';
import { profiles } from '../lib/profile.js';
import { SignIn } from '../views/account.jsx';

const PK_COOKIE = 'r4k_pk';
Expand All @@ -23,23 +25,46 @@ export function registerAuth(app) {
/** Request a link. The answer is identical whether or not the address exists. */
app.post('/api/auth/magic', async (c) => {
const body = await c.req.parseBody();
const json = (c.req.header('accept') ?? '').includes('application/json');

/*
* The signup form carries the shared profile fields; plain sign-in does not and
* is never asked for them. They are checked here, before any link exists, and
* a refusal goes back to the form with what was typed. The same answer for a
* known or unknown address, so it says nothing about who has an account.
*/
let profile = null;
if (body.mode === 'signup') {
const check = validateProfile(body);
if (!check.ok) {
if (json) return c.json({ error: 'Some fields need fixing.', errors: check.errors }, 400);
return c.html(
await render(
<SignIn mode="signup" next={body.next} values={body} errors={check.errors} />,
),
400,
);
}
profile = check.profile;
}

const email = String(body.email ?? '')
.trim()
.toLowerCase();
const next = String(body.next ?? '/settings');
if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
try {
const url = await auth.createLoginLink(email, { next });
const url = await auth.createLoginLink(email, { next, profile });
await sendLoginLink({ email, url });
} catch (err) {
console.error('[auth] link send failed:', err.message);
if (!config.mail.enabled)
console.log(
`[auth] mail is not configured; the link would have been ${await auth.createLoginLink(email, { next })}`,
`[auth] mail is not configured; the link would have been ${await auth.createLoginLink(email, { next, profile })}`,
);
}
}
if ((c.req.header('accept') ?? '').includes('application/json')) return c.json({ ok: true });
if (json) return c.json({ ok: true });
return c.html(await render(<SignIn mode="login" sent />));
});

Expand All @@ -57,6 +82,14 @@ export function registerAuth(app) {
),
400,
);
// The fields typed on the signup form, saved now that the account exists and
// the link has proved the mailbox. A failure here never costs anybody their
// sign-in: settings asks again.
if (result.profile) {
await saveSignupProfile(profiles, result.user.id, result.profile, {
email: result.user.email,
}).catch((err) => console.error('[auth] signup profile save failed:', err.message));
}
c.header('set-cookie', auth.sessionCookie(result.sessionId));
const next = c.req.query('next');
return c.redirect(next?.startsWith('/') ? next : '/settings', 303);
Expand Down
28 changes: 27 additions & 1 deletion apps/web/src/routes/pages.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { validateProfile } from '@profullstack/profile';
import * as auth from '@r4ck/auth';
import { config } from '@r4ck/config';
import * as accounts from '@r4ck/db/accounts';
import { accessFor } from '../lib/gate.js';
import { Denied, notice, render, requireUser, respond, wantsJson } from '../lib/http.js';
import { profiles } from '../lib/profile.js';
import * as svc from '../lib/service.js';
import { Settings } from '../views/account.jsx';
import { Deals } from '../views/deals.jsx';
Expand Down Expand Up @@ -105,10 +107,11 @@ export function registerPages(app) {
/* ------------------------------------------------------------ account -- */
app.get('/settings', async (c) => {
const user = requireUser(c);
const [keys, passkeys, saved] = await Promise.all([
const [keys, passkeys, saved, contact] = await Promise.all([
auth.listApiKeys(user.id),
accounts.listPasskeys(user.id),
svc.listSaved(user),
profiles.get(user.id),
]);
return c.html(
await render(
Expand All @@ -117,12 +120,34 @@ export function registerPages(app) {
keys={keys}
passkeys={passkeys}
saved={saved.saved}
contact={contact ?? { email: user.email }}
freshKey={c.req.query('key')}
{...notice(c)}
/>,
),
);
});
// The shared profile (names, company, title, LinkedIn), for a page without
// script; /api/v1/me/profile is the same thing for API callers. Not under
// /api/, where every answer is JSON, so a plain form post gets its redirect.
app.post('/settings/contact', async (c) => {
const user = requireUser(c);
const body = await c.req.parseBody();
const check = validateProfile({ ...body, email: user.email });
if (!check.ok)
return respond(c, {
json: { error: 'Some fields need fixing.', errors: check.errors },
status: 400,
redirectTo: '/settings',
error: Object.values(check.errors)[0],
});
const profile = await profiles.save(user.id, check.profile);
return respond(c, {
json: { profile },
redirectTo: '/settings',
notice: 'Contact details saved.',
});
});
app.post('/api/keys', async (c) => {
const user = requireUser(c);
const body = await c.req.parseBody();
Expand Down Expand Up @@ -159,6 +184,7 @@ export function registerPages(app) {
const user = requireUser(c);
const { sql } = await import('@r4ck/db');
await sql`delete from users where id = ${user.id}`;
await profiles.remove(user.id);
c.header('set-cookie', auth.sessionCookie('', { clear: true }));
return respond(c, { redirectTo: '/', notice: 'Account deleted.' });
});
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/routes/static.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { join } from 'node:path';
import { PROFILE_ENHANCE_SCRIPT } from '@profullstack/profile';
import { config } from '@r4ck/config';
import { sql } from '@r4ck/db';
import { assetVersion, PUBLIC } from '../lib/assets.js';
Expand Down Expand Up @@ -50,6 +51,14 @@ export function registerStatic(app) {
? serve(c, `icons/${c.req.param('file')}`, { cache: 'public, max-age=86400' })
: c.notFound(),
);
// The signup form's company-from-work-email helper, from @profullstack/profile.
// Served as a file because the policy allows no inline script.
app.get('/vendor-profile.js', (c) =>
c.body(PROFILE_ENHANCE_SCRIPT, 200, {
'content-type': TYPES.js,
'cache-control': 'public, max-age=3600',
}),
);
app.get('/sw.js', (c) => serve(c, 'sw.js', { cache: 'no-cache' }));
// curl -fsSL https://r4ck.dev/install.sh | sh
app.get('/install.sh', (c) => serve(c, 'install.sh', { cache: 'public, max-age=300' }));
Expand Down
47 changes: 45 additions & 2 deletions apps/web/src/views/account.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
import { COLUMNS as PROFILE_COLUMNS, renderProfileFields } from '@profullstack/profile';
import { config } from '@r4ck/config';
import { raw } from 'hono/html';
import { ago } from './home.jsx';
import { Layout } from './Layout.jsx';

export function SignIn({ mode = 'login', next, sent = false, error, user = null }) {
/*
* The rest of the shared profile (@profullstack/profile), asked for on the signup
* page only. Email is the form's own box, so the package renders the other five.
* A plain sign-in never sees or needs them.
*/
const PROFILE_FIELDS = PROFILE_COLUMNS.filter((f) => f !== 'email');

export function SignIn({
mode = 'login',
next,
sent = false,
error,
user = null,
values = null,
errors = null,
}) {
const signup = mode === 'signup';
return (
<Layout
user={user}
path={signup ? '/signup' : '/login'}
title={signup ? 'Create an account' : 'Sign in'}
head={signup ? <script src="/vendor-profile.js" defer></script> : null}
>
<section class="auth">
<h1>{signup ? 'Create an account' : 'Sign in'}</h1>
Expand All @@ -27,6 +45,7 @@ export function SignIn({ mode = 'login', next, sent = false, error, user = null
<>
<form method="post" action="/api/auth/magic" class="stack">
<input type="hidden" name="next" value={next ?? '/settings'} />
{signup ? <input type="hidden" name="mode" value="signup" /> : null}
<label>
Email
<input
Expand All @@ -35,8 +54,19 @@ export function SignIn({ mode = 'login', next, sent = false, error, user = null
required
autocomplete="email"
placeholder="you@example.com"
value={values?.email ?? undefined}
/>
</label>
{errors?.email ? <p class="pfs-profile__error">{errors.email}</p> : null}
{signup
? raw(
renderProfileFields({
fields: PROFILE_FIELDS,
values: values ?? {},
errors: errors ?? {},
}),
)
: null}
<button class="button primary" type="submit">
{signup ? 'Send me a link' : 'Email me a link'}
</button>
Expand Down Expand Up @@ -68,7 +98,7 @@ export function SignIn({ mode = 'login', next, sent = false, error, user = null
);
}

export function Settings({ user, keys, passkeys, saved, freshKey, notice, error }) {
export function Settings({ user, keys, passkeys, saved, contact = null, freshKey, notice, error }) {
return (
<Layout user={user} path="/settings" title="Settings">
<header class="page-head">
Expand Down Expand Up @@ -181,6 +211,19 @@ export function Settings({ user, keys, passkeys, saved, freshKey, notice, error
</p>
</section>
</div>
<section class="panel" id="contact">
<h2>Contact details</h2>
<p class="muted small">
Private to your account. Sent with <code>{user.email}</code>. Also at{' '}
<code>/api/v1/me/profile</code>.
</p>
<form method="post" action="/settings/contact" class="stack">
{raw(renderProfileFields({ fields: PROFILE_FIELDS, values: contact ?? {} }))}
<button class="button primary small" type="submit">
Save contact details
</button>
</form>
</section>
<section class="panel">
<h2>Saved searches</h2>
{saved.length ? (
Expand Down
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.

Loading
Loading