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 @@
"start": "bun src/main.js"
},
"dependencies": {
"@profullstack/profile": "0.1.2",
"@profullstack/x402-gateway": "^0.11.0",
"@simplewebauthn/browser": "^13",
"@simplewebauthn/server": "^13.2.0",
Expand Down
6 changes: 6 additions & 0 deletions apps/web/public/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ label.f { display: flex; flex-direction: column; gap: 6px; font-size: 13px; colo
.grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
form.stack { gap: 12px; }
.err { color: var(--alert); font-size: 13px; min-height: 18px; }
/* The shared profile fields (@profullstack/profile renderProfileFields), made to read like label.f. */
.pfs-profile { border: 0; padding: 0; margin: 0; min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.pfs-profile__row { display: flex; flex-direction: column; gap: 6px; }
.pfs-profile__row label { font-size: 13px; color: var(--muted); }
.pfs-profile__optional { color: var(--dim); }
.pfs-profile__error { color: var(--alert); font-size: 13px; margin: 0; }

/* modal + toast */
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--line-2); border-radius: 18px; padding: 0; width: min(560px, calc(100vw - 32px)); max-height: calc(100vh - 48px); }
Expand Down
81 changes: 66 additions & 15 deletions apps/web/public/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ async function api(method, path, body) {
go('/signin');
throw new ApiError('Please sign in');
}
if (!res.ok) throw new ApiError(data.error || `Request failed (${res.status})`);
if (!res.ok) throw Object.assign(new ApiError(data.error || `Request failed (${res.status})`), { errors: data.errors });
return data;
}
const get = (p) => api('GET', p);
Expand Down Expand Up @@ -163,7 +163,7 @@ function go(path) {
}
window.addEventListener('popstate', () => render());
document.addEventListener('click', (e) => {
const a = e.target.closest('a[href^="/app"], a[href^="/portal"], a[href="/signin"]');
const a = e.target.closest('a[href^="/app"], a[href^="/portal"], a[href="/signin"], a[href="/signup"]');
if (a && !e.metaKey && !e.ctrlKey && !a.target) {
e.preventDefault();
go(a.getAttribute('href'));
Expand All @@ -186,7 +186,7 @@ async function render() {
const path = location.pathname;
const q = new URLSearchParams(location.search);
try {
if (path === '/signin') return renderSignin(q);
if (path === '/signin' || path === '/signup') return renderSignin(q, path === '/signup');
await loadMe();
if (path === '/portal/records') return renderRecords(q);
if (path.startsWith('/portal')) return renderPortal();
Expand Down Expand Up @@ -260,32 +260,68 @@ function bindShell() {

/* --------------------------------------------------------------- sign-in -- */

function renderSignin(q) {
/**
* The shared profile fields (@profullstack/profile), from the <template> the
* server renders into the shell, filled with `values`.
*/
function profileFields(values = {}) {
const t = document.getElementById('profile-fields');
if (!t) return '';
const box = document.createElement('div');
box.append(t.content.cloneNode(true));
for (const input of box.querySelectorAll('input')) if (values[input.name] != null) input.setAttribute('value', values[input.name]);
return box.innerHTML;
}

/** Show the field errors a 400 from the profile validation carried, beside each box. */
function showFieldErrors(form, errors = {}) {
form.querySelectorAll('.pfs-profile__error').forEach((p) => p.remove());
form.querySelectorAll('[aria-invalid]').forEach((i) => i.removeAttribute('aria-invalid'));
for (const [name, msg] of Object.entries(errors)) {
const input = form.querySelector(`[name="${name}"]`);
if (!input) continue;
input.setAttribute('aria-invalid', 'true');
const p = document.createElement('p');
p.className = 'pfs-profile__error';
p.setAttribute('role', 'alert');
p.textContent = msg;
input.after(p);
}
}

function renderSignin(q, signup = false) {
const email = q.get('email') ?? '';
root.innerHTML = `<div class="center"><div class="auth">
<a class="brand" href="/" style="padding:0">${logo} tleehealth</a>
<h1>Sign in</h1>
<h1>${signup ? 'Create your account' : 'Sign in'}</h1>
${q.get('error') === 'expired' ? '<p class="err">That link has expired or was already used. Send a new one.</p>' : ''}
<form id="link-form" class="stack">
<label class="f">Email<input name="email" type="email" autocomplete="email webauthn" required value="${esc(email)}"></label>
<button class="primary" type="submit">Email me a sign-in link</button>
<label class="f">Email<input name="email" type="email" autocomplete="email${signup ? '' : ' webauthn'}" required value="${esc(email)}"></label>
${signup ? profileFields() : ''}
<button class="primary" type="submit">${signup ? 'Email me a link to finish' : 'Email me a sign-in link'}</button>
<p class="err" id="link-err"></p>
</form>
<div class="or">or</div>
<button id="passkey-btn" type="button">Sign in with a passkey</button>
<p class="dim small">No passwords. New here? Enter your email and the link creates your account. Patients: use the email your practice has on file.</p>
${signup ? '' : `<div class="or">or</div>
<button id="passkey-btn" type="button">Sign in with a passkey</button>`}
<p class="dim small">${signup ? 'Already have an account? <a href="/signin">Sign in</a>.' : 'No passwords. New here? <a href="/signup">Create an account</a>, or enter your email and the link creates one. Patients: use the email your practice has on file.'}</p>
</div></div>`;
document.getElementById('link-form').onsubmit = async (e) => {
e.preventDefault();
const { email } = formData(e.target);
const data = formData(e.target);
try {
await post('/auth/link', { email });
e.target.innerHTML = `<div class="card"><div class="card-b"><h2>Check your email</h2><p class="muted">We sent a sign-in link to <b>${esc(email)}</b>. It works once and expires in 20 minutes.</p></div></div>`;
await post('/auth/link', signup ? { ...data, mode: 'signup' } : { email: data.email });
e.target.innerHTML = `<div class="card"><div class="card-b"><h2>Check your email</h2><p class="muted">We sent a sign-in link to <b>${esc(data.email)}</b>. It works once and expires in 20 minutes.</p></div></div>`;
} catch (err) {
showFieldErrors(e.target, err.errors);
document.getElementById('link-err').textContent = err.message;
}
};
document.getElementById('passkey-btn').onclick = passkeySignIn;
if (signup) {
// Fills the company box from a work email; a fresh <script> runs on each render.
const s = document.createElement('script');
s.src = '/assets/profile.js';
root.append(s);
} else document.getElementById('passkey-btn').onclick = passkeySignIn;
}

async function passkeySignIn() {
Expand Down Expand Up @@ -1193,7 +1229,7 @@ async function viewBilling(q) {
/* -------------------------------------------------------------- settings -- */

async function viewSettings() {
const [{ keys }, cs] = await Promise.all([get('/keys'), get(`/orgs/${state.orgId}/call-settings`)]);
const [{ keys }, cs, { profile }] = await Promise.all([get('/keys'), get(`/orgs/${state.orgId}/call-settings`), get('/me/profile')]);
const s = cs.settings;
const html = `
<div class="head"><h1>Settings</h1><button id="signout">Sign out</button></div>
Expand All @@ -1202,6 +1238,9 @@ async function viewSettings() {
<section class="card"><div class="card-h"><h2>You</h2></div><div class="card-b">
<form id="me-form" class="row"><label class="f" style="flex:1">Name<input name="name" value="${esc(state.me.user.name ?? '')}"></label><button type="submit" style="align-self:flex-end">Save</button></form>
<p class="dim small">${esc(state.me.user.email)}</p></div></section>
<section class="card" id="contact"><div class="card-h"><h2>Contact details</h2></div><div class="card-b">
<form id="contact-form" class="stack">${profileFields(profile)}<div><button type="submit">Save contact details</button></div></form>
<p class="dim small">Private to your account. Sent with ${esc(state.me.user.email)}.</p></div></section>
<section class="card"><div class="card-h"><h2>Passkeys</h2><button class="sm" id="add-passkey">Add a passkey</button></div>
<div class="card-b muted">${state.me.passkeys ? `${state.me.passkeys} passkey${state.me.passkeys === 1 ? '' : 's'} saved. Sign in with Face ID, Touch ID or your security key.` : 'Add one to sign in without waiting for an email.'}</div></section>
${is(ADMIN) ? `<section class="card"><div class="card-h"><h2>AI calls</h2>${cs.calling_configured ? '' : '<span class="badge b-warn">not connected yet</span>'}</div><div class="card-b">
Expand Down Expand Up @@ -1232,6 +1271,18 @@ async function viewSettings() {
e.preventDefault();
act(() => api('PATCH', '/me', formData(e.target)), 'Saved');
};
document.getElementById('contact-form').onsubmit = async (e) => {
e.preventDefault();
try {
// Every box, blanks included, so clearing one clears it.
await api('PATCH', '/me/profile', Object.fromEntries(new FormData(e.target)));
showFieldErrors(e.target);
toast('Contact details saved');
} catch (err) {
showFieldErrors(e.target, err.errors);
toast(err.message, true);
}
};
document.getElementById('calls-form')?.addEventListener('submit', (e) => {
e.preventDefault();
const d = formData(e.target);
Expand Down
1 change: 1 addition & 0 deletions apps/web/public/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@
<div id="root"><div class="boot">tleehealth</div></div>
<div id="toast" role="status" aria-live="polite"></div>
<dialog id="modal"></dialog>
<!--profile-fields-->
</body>
</html>
2 changes: 1 addition & 1 deletion apps/web/public/sw.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Minimal service worker: makes the app installable and keeps the shell offline.
// Bump VERSION on every shell change or returning visitors keep the old page.
const VERSION = 'tleehealth-v5';
const VERSION = 'tleehealth-v6';
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(VERSION).then((c) => c.addAll(['/', '/app', '/assets/app.js', '/assets/app.css', '/icon.svg', '/manifest.webmanifest'])));
self.skipWaiting();
Expand Down
41 changes: 38 additions & 3 deletions apps/web/src/api.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { db, orgs } from '@tleehealth/db';
import { createCheckout, paymentsEnabled, settleWebhook, verifyWebhook } from '@tleehealth/payments';
import { createProfileStore, mountProfileRoutes, saveSignupProfile, validateProfile } from '@profullstack/profile';
import { Hono } from 'hono';
import { getCookie } from 'hono/cookie';
import * as auth from './auth.js';
Expand Down Expand Up @@ -114,12 +115,21 @@ async function personIn(orgId, id, types, what = 'person') {
/* ----------------------------------------------------------------- sign-in -- */

api.post('/auth/link', async (c) => {
const { email } = await body(c);
const e = str(email, 254)?.toLowerCase();
const b = await body(c);
const e = str(b.email, 254)?.toLowerCase();
if (!e || !EMAIL.test(e)) return c.json({ error: 'enter a valid email address' }, 400);
// The signup form carries the shared profile fields; plain sign-in does not and
// is never asked for them. Checked before any link exists, with the same answer
// for a known or unknown address.
let profile = null;
if (b.mode === 'signup') {
const check = validateProfile(b);
if (!check.ok) return c.json({ error: 'Some fields need fixing.', errors: check.errors }, 400);
profile = check.profile;
}
// Same answer whether or not the address has an account.
try {
await sendLoginLink({ email: e, url: await auth.createLoginLink(e) });
await sendLoginLink({ email: e, url: await auth.createLoginLink(e, { profile }) });
} catch (err) {
console.error(`[auth] could not send link: ${err?.message ?? err}`);
}
Expand Down Expand Up @@ -162,6 +172,31 @@ api.post('/auth/passkey/login/verify', async (c) => {

/* ---------------------------------------------------------------- account -- */

/*
* The shared Profullstack profile (@profullstack/profile): email, names, company
* domain, job title and LinkedIn, in user_profiles. The store is made on first use
* because the database is (tests and the CLI load this file without one).
* GET/PUT/PATCH /api/v1/me/profile are the package's own handlers.
*/
let profileStore = null;
export const profiles = new Proxy({}, { get: (_, k) => (profileStore ??= createProfileStore(db()))[k] });
mountProfileRoutes(api, {
path: '/me/profile',
store: profiles,
getUser: async (_req, c) => {
const u = await currentUser(c);
return u ? { id: u.id, email: u.email } : null;
},
});

/** Save what a signup typed, once the link has proved the mailbox. Never blocks sign-in. */
export async function saveSignup(user, profile) {
if (!profile) return;
await saveSignupProfile(profiles, user.id, profile, { email: user.email }).catch((err) =>
console.error(`[auth] signup profile save failed: ${err?.message ?? err}`),
);
}

api.get('/me', async (c) => {
const user = await requireUser(c);
const memberships = await db()`
Expand Down
27 changes: 24 additions & 3 deletions apps/web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ping } from '@tleehealth/db';
import { COLUMNS as PROFILE_COLUMNS, PROFILE_ENHANCE_SCRIPT, renderProfileFields } from '@profullstack/profile';
import { Hono } from 'hono';
import { x402Gateway } from '@profullstack/x402-gateway/hono';
import { createCrawlGate } from './crawl-gate.js';
import { api, coinpayWebhook } from './api.js';
import { api, coinpayWebhook, saveSignup } from './api.js';
import { withPitFooter } from './pit-footer.js';
import * as auth from './auth.js';
import * as calls from './calls.js';
Expand Down Expand Up @@ -39,13 +40,31 @@ app.get('/', (c) => page(c, 'index.html'));
app.get('/healthz', (c) => c.text('ok'));

// The app: one shell for the team, patients and sign-in. It talks only to /api/v1.
const shell = (c) => page(c, 'app/index.html');
for (const p of ['/app', '/app/*', '/signin', '/portal', '/portal/*']) app.get(p, shell);
// The shared profile fields (@profullstack/profile) ride in the shell as a
// <template>: the signup form and Settings clone them, so the markup is the
// package's own. Email is each form's own box, so the package renders the other five.
const PROFILE_TEMPLATE = `<template id="profile-fields">${renderProfileFields({
fields: PROFILE_COLUMNS.filter((f) => f !== 'email'),
})}</template>`;
const shell = async (c) =>
c.html(
await withPitFooter(
pub('app/index.html')
.toString()
.replace('<!--year-->', String(new Date().getFullYear()))
.replace('<!--profile-fields-->', PROFILE_TEMPLATE),
),
);
for (const p of ['/app', '/app/*', '/signin', '/signup', '/portal', '/portal/*']) app.get(p, shell);
app.get('/patient', (c) => c.redirect('/portal', 301));
const asset = (name, type) => (c) =>
c.body(pub(`app/${name}`), 200, { 'content-type': type, 'cache-control': 'no-cache' });
app.get('/assets/app.js', asset('app.js', 'text/javascript; charset=utf-8'));
app.get('/assets/app.css', asset('app.css', 'text/css; charset=utf-8'));
// The signup form's company-from-work-email helper, from @profullstack/profile.
app.get('/assets/profile.js', (c) =>
c.body(PROFILE_ENHANCE_SCRIPT, 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }),
);
// The WebAuthn browser helper, served from our own origin rather than a CDN.
const WEBAUTHN_JS = [join(here, '..', 'node_modules'), join(root, 'node_modules')]
.map((d) => join(d, '@simplewebauthn/browser/dist/bundle/index.umd.min.js'))
Expand All @@ -68,6 +87,7 @@ app.get('/auth/magic', async (c) => {
const token = c.req.query('t');
const s = token ? await auth.consumeLoginLink(token, { userAgent: c.req.header('user-agent') }) : null;
if (!s) return c.redirect('/signin?error=expired', 302);
await saveSignup(s.user, s.profile);
c.header('set-cookie', auth.sessionCookie(s.sessionId));
return c.redirect('/app', 302);
});
Expand Down Expand Up @@ -183,6 +203,7 @@ Pricing: $10 per team seat per month, or $199/month for up to 1,000 seats. Patie
Authenticate with a session cookie or \`Authorization: Bearer th_live_...\` (create a key in the app under Settings).
- GET /health
- GET /me your practices and user type in each
- GET|PUT|PATCH /me/profile your contact details: first_name, last_name, company_domain, job_title, linkedin_url
- GET /schedule?date=YYYY-MM-DD&org=<id> the day's appointments
- GET /orgs/:org/today appointments + refills, labs and summaries waiting on a human
- GET|POST /orgs/:org/people?type=team|patient|lead
Expand Down
33 changes: 29 additions & 4 deletions apps/web/src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,17 @@ export const expectedOrigins = () => {

/* -------------------------------------------------------------- magic link -- */

export async function createLoginLink(email) {
/**
* `profile` is the validated signup form (@profullstack/profile fields), held on
* the token because the account row does not exist until the link is spent.
* Bound as text and cast, so the driver never JSON-encodes the string a second time.
*/
export async function createLoginLink(email, { profile = null } = {}) {
const token = randomBytes(32).toString('base64url');
await db()`
insert into login_tokens (token_hash, email, expires_at)
values (${sha(token)}, ${email.trim().toLowerCase()}, ${new Date(Date.now() + TOKEN_TTL_MINUTES * 60_000)})`;
insert into login_tokens (token_hash, email, expires_at, profile)
values (${sha(token)}, ${email.trim().toLowerCase()}, ${new Date(Date.now() + TOKEN_TTL_MINUTES * 60_000)},
${profile ? JSON.stringify(profile) : null}::text::jsonb)`;
return `${config.siteUrl}/auth/magic?t=${token}`;
}

Expand All @@ -46,8 +52,27 @@ export async function consumeLoginLink(token, { userAgent } = {}) {
where token_hash = ${sha(token)} and used_at is null and expires_at > now()
returning email`;
if (!row) return null;
const profile = await takeSignupProfile(sha(token));
const user = await findOrCreateUser(row.email);
return { user, sessionId: await startSession(user.id, userAgent) };
return { user, sessionId: await startSession(user.id, userAgent), profile };
}

/**
* The signup fields a just-spent link carried, handed out once and then cleared.
* RETURNING sees the row after the update, so the old value comes from a subquery.
*/
async function takeSignupProfile(tokenHash) {
const [row] = await db()`
update login_tokens t set profile = null
from (
select token_hash, profile from login_tokens
where token_hash = ${tokenHash} and used_at is not null and profile is not null
for update
) old
where t.token_hash = old.token_hash
returning old.profile`;
if (!row) return null;
return typeof row.profile === 'string' ? JSON.parse(row.profile) : row.profile;
}

export async function findOrCreateUser(email) {
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