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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user