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
7 changes: 7 additions & 0 deletions apps/web/public/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
.msg{margin-top:12px;font-size:13px;min-height:18px}
.ok{color:var(--cyan)} .err{color:#ff8a9b}
a{color:var(--cyan)} .home{display:block;text-align:center;margin-top:16px;font-size:12px}
/* The shared signup fields (@profullstack/profile): the label/input rules above style them. */
.pfs-profile{border:0;padding:0;margin:0;min-width:0}
.pfs-profile__optional{opacity:.7}
.pfs-profile input[aria-invalid=true]{border-color:#ff8a9b}
.pfs-profile__error{color:#ff8a9b;font-size:12px;margin:4px 0 0}
</style></head>
<body>
<div class="card">
Expand All @@ -37,13 +42,15 @@
<input id="email" type="email" autocomplete="email" />
<label for="password">Password</label>
<input id="password" type="password" autocomplete="current-password" />
<div id="signup-fields" hidden></div>
<div class="row">
<button id="login" class="primary">Log in</button>
<button id="signup">Sign up</button>
</div>
<div id="msg" class="msg"></div>
<a class="home" href="/">← back to tronbrowser.dev</a>
</div>
<script src="/api/v1/profile/signup-fields.js"></script>
<script src="/login.js"></script>
<!--pit-footer--><!--/pit-footer-->
</body></html>
42 changes: 40 additions & 2 deletions apps/web/public/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,42 @@
// plain link (full-page redirect through the OAuth flow).
const $ = (id) => document.getElementById(id);

// Sign up also asks for the shared profile fields (@profullstack/profile),
// written into #signup-fields by /api/v1/profile/signup-fields.js. Log in never does.
const PROFILE_FIELDS = ['first_name', 'last_name', 'company_domain', 'job_title', 'linkedin_url'];

function profileBody() {
const out = {};
for (const name of PROFILE_FIELDS) {
const input = document.querySelector(`#signup-fields [name="${name}"]`);
if (input) out[name] = input.value;
}
return out;
}

function markErrors(errors) {
for (const input of document.querySelectorAll('#signup-fields input')) {
if (errors && errors[input.name]) input.setAttribute('aria-invalid', 'true');
else input.removeAttribute('aria-invalid');
}
}

async function go(path) {
$('msg').textContent = '…';
$('msg').className = 'msg';
const signup = path.endsWith('signup');
const r = await fetch(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email: $('email').value, password: $('password').value }),
body: JSON.stringify({
email: $('email').value,
password: $('password').value,
...(signup ? profileBody() : {}),
}),
});
const d = await r.json().catch(() => ({}));
if (signup) markErrors(d.errors);
if (r.ok) {
$('msg').textContent = d.message || 'Signed in.';
$('msg').className = 'msg ok';
Expand All @@ -23,7 +49,19 @@ async function go(path) {
}

$('login').onclick = () => go('/api/auth/login');
$('signup').onclick = () => go('/api/auth/signup');
$('signup').onclick = () => {
const fields = $('signup-fields');
// First press shows the signup fields; the second sends them.
if (fields.hidden) {
fields.hidden = false;
$('password').autocomplete = 'new-password';
$('msg').textContent = 'Add your name, then press Sign up again.';
$('msg').className = 'msg';
document.querySelector('#signup-fields [name="first_name"]')?.focus();
return;
}
go('/api/auth/signup');
};

// Surface notices (e.g. expired verification link).
const msg = new URLSearchParams(location.search).get('msg');
Expand Down
19 changes: 19 additions & 0 deletions packages/storage/migrations-pg/0008_user_profiles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- TronBrowser Postgres schema: 0008_user_profiles
-- The shared Profullstack profile (@profullstack/profile): email, first and last
-- name, company domain, job title and LinkedIn URL, one row per user. Asked for
-- on email signup; owner-only through /api/v1/me/profile. Generated with
-- `profile schema --key user_id --key-type text`. No backfill: users has no name.

create table if not exists "user_profiles" (
"user_id" text 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");
17 changes: 17 additions & 0 deletions packages/storage/migrations/0008_user_profiles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- The shared Profullstack profile (@profullstack/profile): email, first and last
-- name, company domain, job title and LinkedIn URL, one row per user. Legacy
-- SQLite edition of migrations-pg/0008_user_profiles.sql.

create table if not exists "user_profiles" (
"user_id" text primary key,
"email" text,
"first_name" text,
"last_name" text,
"company_domain" text,
"job_title" text,
"linkedin_url" text,
"created_at" text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
"updated_at" text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','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");
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

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

1 change: 1 addition & 0 deletions services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@logicsrc/agentswarm": "^0.1.0",
"@profullstack/coinpay-onboard": "0.2.0",
"@profullstack/libsql-pg": "^0.1.1",
"@profullstack/profile": "0.1.3",
"@profullstack/x402-gateway": "^0.11.0",
"@tronbrowser/sdk": "workspace:*",
"deepagents": "^1.10.5",
Expand Down
11 changes: 10 additions & 1 deletion services/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { extLoginTarget } from './ext-login.js';
import { assertDatabaseUrl, db } from './db.js';
import { pushService } from './push/routes.js';
import { createCrawlGate, crawlCheck } from './crawl-gate.js';
import { mountProfile, profileStore, saveSignup, signupProfileErrors } from './profile.js';

// Fail fast: a missing or non-Postgres DATABASE_URL is a deploy error, not a
// condition to limp along under (there is no file-database fallback).
Expand Down Expand Up @@ -170,11 +171,16 @@ app.get('/api/auth/coinpay/callback', async (c) => {

/* ---------- Email + password ---------- */
app.post('/api/auth/signup', async (c) => {
const { email, password } = await c.req.json().catch(() => ({}));
const body = await c.req.json().catch(() => ({}));
const { email, password } = body;
if (!email || !password || password.length < 8) return c.json({ error: 'email and 8+ char password required' }, 400);
// The shared profile fields (first + last name required), checked before any account exists.
const invalid = signupProfileErrors(body);
if (invalid) return c.json(invalid, 400);
if (await userByEmail(email)) return c.json({ error: 'email already registered' }, 409);
const id = uuid();
await createUser({ id, authMethod: 'password', email, passwordHash: hashPassword(password), emailVerified: false });
await saveSignup(profileStore(), id, body, email);
const vtok = token();
await putEmailToken(vtok, id, 'verify', EMAIL_TOKEN_TTL);
const link = `${baseUrl(c)}/api/auth/verify?token=${vtok}`;
Expand Down Expand Up @@ -212,6 +218,9 @@ app.get('/api/auth/me', async (c) => {
return c.json({ signedIn: true, id: user.id, authMethod: user.auth_method, email: user.email, emailVerified: !!user.email_verified });
});

/* ---------- Shared profile (@profullstack/profile): /api/v1/me/profile ---------- */
mountProfile(app, { currentUser });

/* ---------- Settings sync ---------- */
app.get('/api/settings', async (c) => {
const user = await currentUser(c);
Expand Down
74 changes: 74 additions & 0 deletions services/api/src/profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createClient } from '@libsql/client';
import { createProfileStore } from '@profullstack/profile';
import { Hono } from 'hono';
import { beforeAll, describe, expect, it } from 'vitest';
import { mountProfile, saveSignup, signupProfileErrors, SIGNUP_FIELDS_SCRIPT } from './profile.js';

// The package's store on an in-memory SQLite: what actually lands in user_profiles.
const store = createProfileStore(createClient({ url: 'file::memory:' }));
beforeAll(async () => {
await store.migrate();
});

const SIGNUP = {
email: 'Ada@Acme.com',
password: 'x'.repeat(12),
first_name: 'Ada',
last_name: 'Lovelace',
company_domain: 'https://www.acme.com/about',
job_title: 'VP Engineering',
linkedin_url: 'in/ada-lovelace',
};

describe('signup profile fields', () => {
it('refuses a signup without a name before any account exists', () => {
const invalid = signupProfileErrors({ email: 'ada@acme.com', password: 'x'.repeat(12) });
expect(invalid).not.toBeNull();
expect(Object.keys(invalid!.errors)).toEqual(expect.arrayContaining(['first_name', 'last_name']));
expect(signupProfileErrors(SIGNUP)).toBeNull();
});

it('a signup with the fields stores them, normalized', async () => {
await saveSignup(store, 'user-1', SIGNUP, SIGNUP.email);
expect(await store.get('user-1')).toMatchObject({
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',
});
});

it('the static login page gets the five fields from the package, not email', () => {
for (const name of ['first_name', 'last_name', 'company_domain', 'job_title', 'linkedin_url']) {
expect(SIGNUP_FIELDS_SCRIPT).toContain(`name=\\"${name}\\"`);
}
expect(SIGNUP_FIELDS_SCRIPT).not.toContain('name=\\"email\\"');
});
});

describe('/api/v1/me/profile', () => {
const app = new Hono();
mountProfile(app, {
currentUser: async (c: any) =>
c.req.header('authorization') === 'Bearer ok' ? { id: 'user-1', email: 'ada@acme.com' } : null,
store: () => store,
});

it('is 401 without a session', async () => {
expect((await app.request('/api/v1/me/profile')).status).toBe(401);
});

it('reads and patches the owner profile', async () => {
const got = await app.request('/api/v1/me/profile', { headers: { authorization: 'Bearer ok' } });
expect((await got.json()).profile.job_title).toBe('VP Engineering');
const patched = await app.request('/api/v1/me/profile', {
method: 'PATCH',
headers: { authorization: 'Bearer ok', 'content-type': 'application/json' },
body: JSON.stringify({ job_title: 'CTO' }),
});
expect(patched.status).toBe(200);
expect((await patched.json()).profile.job_title).toBe('CTO');
});
});
86 changes: 86 additions & 0 deletions services/api/src/profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
COLUMNS,
createProfileHandlers,
createProfileStore,
renderProfileFields,
saveSignupProfile,
validateProfile,
type ProfileStore,
} from '@profullstack/profile';
import type { Hono } from 'hono';
import { db } from './db.js';

/**
* The shared Profullstack profile (@profullstack/profile): email, first and
* last name, company domain, job title and LinkedIn URL, in `user_profiles`.
* Asked for on email signup only (never on log in, never for CoinPay accounts),
* and readable/editable by the owner at /api/v1/me/profile.
*/

let _store: ProfileStore | null = null;
export function profileStore(): ProfileStore {
_store ??= createProfileStore(db());
return _store;
}

/** Signup shows the five fields beside the page's own email box. */
export const SIGNUP_FIELDS = COLUMNS.filter((f) => f !== 'email');

/**
* Check a signup body's profile fields before any account exists. Returns the
* error response body, or null when it is fine.
*/
export function signupProfileErrors(body: unknown): { error: string; errors: Record<string, string> } | null {
const check = validateProfile(body as Record<string, unknown>);
if (check.ok) return null;
const errors = check.errors as Record<string, string>;
return { error: Object.values(errors)[0] ?? 'Some fields need fixing.', errors };
}

/** Save them once the user exists. Logged, never fatal: the API can fill them later. */
export async function saveSignup(
store: ProfileStore,
userId: string,
body: unknown,
email: string,
): Promise<void> {
try {
await saveSignupProfile(store, userId, body as Record<string, unknown>, { email });
} catch (e) {
console.error('[signup] profile save failed:', (e as Error).message);
}
}

/**
* The login page is static, so its signup fields come from here: a same-origin
* script (the CSP allows no inline script) that writes the package's own
* markup into #signup-fields.
*/
export const SIGNUP_FIELDS_SCRIPT = `(()=>{const el=document.getElementById('signup-fields');if(el)el.innerHTML=${JSON.stringify(
renderProfileFields({ fields: SIGNUP_FIELDS }),
)};})();\n`;

/** GET/PUT/PATCH /api/v1/me/profile plus the signup-fields script. */
export function mountProfile(
app: Hono,
{ currentUser, store = profileStore }: { currentUser: (c: any) => Promise<{ id: string; email: string | null } | null>; store?: () => ProfileStore },
): void {
let handlers: ReturnType<typeof createProfileHandlers> | null = null;
const h = () =>
(handlers ??= createProfileHandlers({
store: store(),
getUser: async (_req, c) => {
const user = await currentUser(c);
return user ? { id: user.id, email: user.email } : null;
},
}));
app.get('/api/v1/me/profile', (c) => h().GET(c.req.raw, c));
app.put('/api/v1/me/profile', (c) => h().PUT(c.req.raw, c));
app.patch('/api/v1/me/profile', (c) => h().PATCH(c.req.raw, c));
app.get('/api/v1/profile/signup-fields.js', (c) =>
c.body(SIGNUP_FIELDS_SCRIPT, 200, {
'content-type': 'text/javascript; charset=utf-8',
'cache-control': 'public, max-age=300',
}),
);
}
Loading