Client avatars, and Robert Pérez on the demo account

`users.image` was null on every seeded customer, and it is the face on
every review a pro has — `pro.reviews` selects it as `authorImage` — as
well as the chat header. So the one screen meant to prove other people
have used this rendered as a column of blank circles.

Five customers now carry a portrait, seeded with the source url and
rewritten to the bucket like pro_media, and the first of them — the
dev-login account the demo signs in as — is Robert Pérez.

assets:migrate only knew about pro_media and job photos, so an avatar
would have stayed on someone else's CDN indefinitely. It has a users
pass now, which also catches the provider avatar a social sign-in writes
straight onto the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfa
2026-08-23 11:02:57 -04:00
co-authored by Claude Opus 5
parent 1808ad4cba
commit ff1882598c
9 changed files with 299 additions and 21 deletions
+20 -16
View File
@@ -6,6 +6,7 @@ import { SignedOut } from '@/components/chrome/signed-out';
import { LocationGroup } from '@/components/settings/location-group'; import { LocationGroup } from '@/components/settings/location-group';
import { AccountSection } from '@/components/settings/account-section'; import { AccountSection } from '@/components/settings/account-section';
import { AccountFooter } from '@/components/settings/account-footer'; import { AccountFooter } from '@/components/settings/account-footer';
import { ImpersonationBanner, SessionList } from '@/components/settings/session-list';
import type { PhoneTab } from '@/components/chrome/phone-tabs'; import type { PhoneTab } from '@/components/chrome/phone-tabs';
/** /**
@@ -82,13 +83,13 @@ function SignedIn({ me, onNavigate }: { me: Me; onNavigate?: (tab: PhoneTab) =>
onSettled: () => void utils.notification.get.invalidate(), onSettled: () => void utils.notification.get.invalidate(),
}); });
const sessions = api.user.sessions.useQuery();
const p = prefs.data; const p = prefs.data;
const isPro = me.role === 'pro'; const isPro = me.role === 'pro';
return ( return (
<PanelShell> <PanelShell>
<ImpersonationBanner />
<AccountSection me={me} /> <AccountSection me={me} />
<LocationGroup isPro={isPro} /> <LocationGroup isPro={isPro} />
@@ -125,7 +126,7 @@ function SignedIn({ me, onNavigate }: { me: Me; onNavigate?: (tab: PhoneTab) =>
/> />
</SettingsGroup> </SettingsGroup>
{isPro && ( {isPro ? (
<SettingsGroup <SettingsGroup
title="Working" title="Working"
note="Changing your trades sends your profile back for review." note="Changing your trades sends your profile back for review."
@@ -142,21 +143,24 @@ function SignedIn({ me, onNavigate }: { me: Me; onNavigate?: (tab: PhoneTab) =>
onClick={onNavigate ? () => onNavigate('profile') : undefined} onClick={onNavigate ? () => onNavigate('profile') : undefined}
/> />
</SettingsGroup> </SettingsGroup>
) : (
/*
The same slot, the other side of the market. A cold deck is what kills
this marketplace, so the one screen every customer eventually opens is
worth a door into pro onboarding — the profile tab already makes this
offer, and settings is where somebody goes when they are looking for
something they have not found.
*/
<SettingsGroup title="Working" note="Verification takes about a day.">
<SettingsRow
label="Work on Linkdr"
hint="Set up a pro profile and start getting jobs"
href="/pro/onboarding"
/>
</SettingsGroup>
)} )}
<SettingsGroup title="Security"> <SessionList />
<SettingsRow
label="Signed-in devices"
value={sessions.data ? String(sessions.data.length) : '…'}
/>
{sessions.data?.some((s) => s.isImpersonated) && (
<SettingsRow
label="Admin is viewing your account"
hint="A support session is active"
danger
/>
)}
</SettingsGroup>
<SettingsGroup title="Legal and data"> <SettingsGroup title="Legal and data">
{/* {/*
@@ -0,0 +1,105 @@
'use client';
import { Banner, SettingsGroup } from '@/components/ui';
import { api } from '@/lib/trpc';
import { describeDevice } from '@/lib/user-agent';
import { cn, formatRelativeTime } from '@/lib/utils';
/**
* Where you are signed in.
*
* This group used to be one row reading "Signed-in devices — 3". A count is the
* least useful rendering of this data: the only reason to look is to find a
* device you do not recognise, and a number cannot be recognised or not. Every
* field below already came back from `user.sessions` and was being thrown away.
*
* There is no revoke button because there is no revoke procedure. Adding one is
* a real piece of work — it has to invalidate a live bearer token — and a button
* that greys itself out would be a worse answer than the honest note.
*/
/**
* The alarm, hoisted to the top of the screen.
*
* Separate from the list rather than rendered above it, because it belongs
* where it is seen without scrolling — §6.5 is for a state the whole screen is
* in, and this one wants answering before anything else on the page. Both
* components read the same query key, so this costs no second request.
*/
export function ImpersonationBanner() {
const sessions = api.user.sessions.useQuery();
if (!sessions.data?.some((s) => s.isImpersonated)) return null;
return (
<Banner tone="error" role="alert" title="Support is viewing your account" className="mb-8">
An administrator has an active session on your account. Contact us if you did not ask for
help.
</Banner>
);
}
export function SessionList() {
const sessions = api.user.sessions.useQuery();
if (sessions.isLoading) {
return (
<SettingsGroup title="Security">
<div className="h-28 animate-pulse bg-inset" />
</SettingsGroup>
);
}
const rows = sessions.data ?? [];
if (rows.length === 0) {
return (
<SettingsGroup title="Security">
<div className="px-4 py-3.5 text-body-sm text-muted">No other sessions.</div>
</SettingsGroup>
);
}
return (
<SettingsGroup
title="Security"
note="To sign a device out, sign out on it. Remote sign-out is not built yet."
>
{rows.map((s) => {
const device = describeDevice(s.userAgent);
return (
<div key={s.id} className="flex items-center gap-3 px-4 py-3.5">
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span
className={cn(
'truncate text-body-sm',
s.isImpersonated ? 'text-danger' : 'text-strong',
)}
>
{s.isImpersonated ? 'Support session' : (device ?? 'Unrecognised device')}
</span>
{s.isCurrent && (
// A word, not a dot or a colour. §8 — and it is the only
// thing on this screen that answers "which one is me?".
<span className="shrink-0 rounded-md bg-accent-soft px-1.5 py-0.5 text-meta text-accent">
This device
</span>
)}
</span>
{/*
No IP address here, though `sessions` returns one. A raw address
— and on IPv6 it is forty characters of hex — is not something a
person can recognise or fail to recognise, which is the only
question this list exists to answer. It pushed the one readable
field off the end of the row. It stays in the data export;
putting it back needs a geo lookup that turns it into a city.
*/}
<span className="mt-0.5 block truncate text-meta text-muted">
Signed in {formatRelativeTime(s.createdAt)}
</span>
</span>
</div>
);
})}
</SettingsGroup>
);
}
+57
View File
@@ -0,0 +1,57 @@
/**
* "Chrome on Windows" out of a user-agent string.
*
* Deliberately not a UA-parsing library. The security screen asks one question —
* "do I recognise this?" — and the answer needs a browser and an operating
* system, not a version tree. Anything a real parser knows beyond that would be
* detail the reader has to skip past to answer it.
*
* Order matters in both tables and is the whole trick: every Chromium browser
* still says "Chrome", every iOS browser still says "Safari", and Edge says
* both. So the most specific claim is tested first and the generic fallbacks
* come last. Reversing either list quietly relabels half the world as Chrome.
*/
const BROWSERS: [needle: string, name: string][] = [
['Edg/', 'Edge'],
['OPR/', 'Opera'],
['SamsungBrowser/', 'Samsung Internet'],
['Firefox/', 'Firefox'],
['Chrome/', 'Chrome'],
['Safari/', 'Safari'],
];
const SYSTEMS: [needle: string, name: string][] = [
['iPhone', 'iPhone'],
['iPad', 'iPad'],
['Android', 'Android'],
['Windows', 'Windows'],
// Before "Mac": an iPhone claiming desktop mode still says "like Mac OS X".
['Macintosh', 'Mac'],
['Mac OS', 'Mac'],
['Linux', 'Linux'],
];
function match(ua: string, table: [string, string][]): string | null {
for (const [needle, name] of table) {
if (ua.includes(needle)) return name;
}
return null;
}
/**
* A short device label, or `null` when the string says nothing useful.
*
* Null rather than "Unknown on Unknown": the caller has somewhere honest to put
* a missing answer, and a row confidently reporting two unknowns reads like a
* bug in the parser rather than a gap in the data.
*/
export function describeDevice(userAgent: string | null | undefined): string | null {
if (!userAgent) return null;
const browser = match(userAgent, BROWSERS);
const system = match(userAgent, SYSTEMS);
if (browser && system) return `${browser} on ${system}`;
return browser ?? system;
}
+1
View File
@@ -61,5 +61,6 @@ export const resolveSession: SessionResolver = async (req: Request): Promise<Ses
email: user.email ?? null, email: user.email ?? null,
phone: user.phone ?? user.phoneNumber ?? null, phone: user.phone ?? user.phoneNumber ?? null,
verificationStatus, verificationStatus,
sessionId: (result.session as { id?: string } | undefined)?.id,
}; };
}; };
+54
View File
@@ -0,0 +1,54 @@
/**
* The device labeller behind the security screen.
*
* The cases that matter are the impostors: every Chromium browser claims to be
* Chrome, every iOS browser claims to be Safari, and Edge claims both. A parser
* that gets the obvious strings right and these wrong is a parser that labels
* most of the world "Chrome".
*/
import { describe, expect, it } from 'vitest';
import { describeDevice } from '../src/lib/user-agent';
const UA = {
chromeWindows:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
edgeWindows:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0',
safariIphone:
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
chromeAndroid:
'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36',
firefoxMac:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:130.0) Gecko/20100101 Firefox/130.0',
};
describe('describeDevice', () => {
it('reads the plain cases', () => {
expect(describeDevice(UA.chromeWindows)).toBe('Chrome on Windows');
expect(describeDevice(UA.firefoxMac)).toBe('Firefox on Mac');
});
it('does not call Edge "Chrome", though Edge says it is', () => {
expect(describeDevice(UA.edgeWindows)).toBe('Edge on Windows');
});
it('does not call an iPhone a Mac, though it says "like Mac OS X"', () => {
expect(describeDevice(UA.safariIphone)).toBe('Safari on iPhone');
});
it('prefers Android over the Linux it is built on', () => {
expect(describeDevice(UA.chromeAndroid)).toBe('Chrome on Android');
});
it('returns what it knows when it only knows half', () => {
expect(describeDevice('Mozilla/5.0 (Windows NT 10.0)')).toBe('Windows');
expect(describeDevice('Firefox/130.0')).toBe('Firefox');
});
it('says nothing rather than guessing', () => {
expect(describeDevice(null)).toBeNull();
expect(describeDevice(undefined)).toBeNull();
expect(describeDevice('')).toBeNull();
expect(describeDevice('curl/8.7.1')).toBeNull();
});
});
+10
View File
@@ -17,6 +17,16 @@ export interface Session {
phone: string | null; phone: string | null;
/** Only present for pros. Gates the procedures that require a live, verified pro. */ /** Only present for pros. Gates the procedures that require a live, verified pro. */
verificationStatus: VerificationStatus | null; verificationStatus: VerificationStatus | null;
/**
* Which session row this request arrived on, so the security screen can say
* "this device" and mean it.
*
* Optional because not every caller has one: the server-side caller and the
* test fakes construct a session directly, with no row behind it. Anything
* reading this must treat "absent" as "cannot tell", never as "not current" —
* marking the wrong device as the current one is worse than marking none.
*/
sessionId?: string;
} }
/** /**
+8 -1
View File
@@ -364,7 +364,14 @@ export const userRouter = router({
.orderBy(desc(schema.sessions.createdAt)); .orderBy(desc(schema.sessions.createdAt));
// sessions.token is a live bearer credential and is deliberately not selected. // sessions.token is a live bearer credential and is deliberately not selected.
return rows.map((r) => ({ ...r, isImpersonated: r.impersonatedBy !== null })); return rows.map((r) => ({
...r,
isImpersonated: r.impersonatedBy !== null,
// False when the caller has no session id rather than guessing. A device
// list that mislabels which one you are holding is worse than one that
// labels none of them.
isCurrent: ctx.session.sessionId !== undefined && r.id === ctx.session.sessionId,
}));
}), }),
/** /**
+23 -1
View File
@@ -163,7 +163,29 @@ async function main() {
jobPhotos += outward.length; jobPhotos += outward.length;
} }
console.log(`\n${moved} pro images and ${jobPhotos} job photos now served from ${origin}`); // Account avatars. Seeded clients carry one, and a social sign-in writes the
// provider's CDN url straight onto the row — both point outward.
const withAvatars = await db
.select({ id: schema.users.id, image: schema.users.image })
.from(schema.users)
.where(sql`${schema.users.image} IS NOT NULL`);
let avatars = 0;
for (const user of withAvatars) {
if (!user.image || isLocal(user.image)) continue;
const hosted = await adopt(user.image, 'avatars');
if (!hosted) continue;
await db
.update(schema.users)
.set({ image: hosted })
.where(sql`${schema.users.id} = ${user.id}`);
avatars += 1;
}
console.log(
`\n${moved} pro images, ${jobPhotos} job photos and ${avatars} avatars ` +
`now served from ${origin}`,
);
const left = ( const left = (
await db.select({ url: schema.proMedia.url }).from(schema.proMedia) await db.select({ url: schema.proMedia.url }).from(schema.proMedia)
+21 -3
View File
@@ -213,7 +213,22 @@ const PROS: SeedPro[] = [
{ name: 'Away Arturo', cat: 'plumber', photo: 'photo-1676210134188-4c05dd172f89', distanceM: 1_100, rating: 4.9, reviews: 20, radius: 15_000, away: true }, { name: 'Away Arturo', cat: 'plumber', photo: 'photo-1676210134188-4c05dd172f89', distanceM: 1_100, rating: 4.9, reviews: 20, radius: 15_000, away: true },
]; ];
const CLIENTS = ['Sofía Guzmán', 'Daniel Miranda', 'Emma Rivera', 'Lucas Ponce', 'Alba Tovar']; /**
* Customers.
*
* The avatar is not decoration: `users.image` is the face on every review a pro
* has (`pro.reviews` selects it as `authorImage`) and on the chat header. Left
* null, every review on every profile in the demo renders faceless, which is
* the one screen meant to look like other people have used this.
*/
const CLIENTS: { name: string; photo: string }[] = [
// The first one is the dev-login account — see the phone note below.
{ name: 'Robert Pérez', photo: 'photo-1500648767791-00dcc994a43e' },
{ name: 'Daniel Miranda', photo: 'photo-1507003211169-0a1dd7228f2d' },
{ name: 'Emma Rivera', photo: 'photo-1494790108377-be9c29b29330' },
{ name: 'Lucas Ponce', photo: 'photo-1506794778202-cad84cf45f1d' },
{ name: 'Alba Tovar', photo: 'photo-1438761681033-6461ffad8d80' },
];
/** /**
* Finished work, per trade, for the review histories below. * Finished work, per trade, for the review histories below.
@@ -370,8 +385,11 @@ async function main() {
const clientRows = await db const clientRows = await db
.insert(schema.users) .insert(schema.users)
.values( .values(
CLIENTS.map((name, i) => ({ CLIENTS.map((c, i) => ({
name, name: c.name,
// Seeded with the source url and rewritten to our bucket by
// `pnpm assets:migrate`, exactly as pro_media is.
image: `https://images.unsplash.com/${c.photo}?w=200&h=200&fit=crop`,
email: `client${i + 1}@linkder.test`, email: `client${i + 1}@linkder.test`,
/** /**
* The first client gets the dev-login number (see web/src/server/dev-login.ts). * The first client gets the dev-login number (see web/src/server/dev-login.ts).