diff --git a/apps/web/src/app/settings-panel.tsx b/apps/web/src/app/settings-panel.tsx index 8ef2090..3510ff5 100644 --- a/apps/web/src/app/settings-panel.tsx +++ b/apps/web/src/app/settings-panel.tsx @@ -6,6 +6,7 @@ import { SignedOut } from '@/components/chrome/signed-out'; import { LocationGroup } from '@/components/settings/location-group'; import { AccountSection } from '@/components/settings/account-section'; import { AccountFooter } from '@/components/settings/account-footer'; +import { ImpersonationBanner, SessionList } from '@/components/settings/session-list'; 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(), }); - const sessions = api.user.sessions.useQuery(); - const p = prefs.data; const isPro = me.role === 'pro'; return ( + + @@ -125,7 +126,7 @@ function SignedIn({ me, onNavigate }: { me: Me; onNavigate?: (tab: PhoneTab) => /> - {isPro && ( + {isPro ? ( onClick={onNavigate ? () => onNavigate('profile') : undefined} /> + ) : ( + /* + 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. + */ + + + )} - - - {sessions.data?.some((s) => s.isImpersonated) && ( - - )} - + {/* diff --git a/apps/web/src/components/settings/session-list.tsx b/apps/web/src/components/settings/session-list.tsx new file mode 100644 index 0000000..3031e95 --- /dev/null +++ b/apps/web/src/components/settings/session-list.tsx @@ -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 ( + + An administrator has an active session on your account. Contact us if you did not ask for + help. + + ); +} + +export function SessionList() { + const sessions = api.user.sessions.useQuery(); + + if (sessions.isLoading) { + return ( + +
+ + ); + } + + const rows = sessions.data ?? []; + + if (rows.length === 0) { + return ( + +
No other sessions.
+
+ ); + } + + return ( + + {rows.map((s) => { + const device = describeDevice(s.userAgent); + return ( +
+ + + + {s.isImpersonated ? 'Support session' : (device ?? 'Unrecognised device')} + + {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?". + + This device + + )} + + {/* + 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. + */} + + Signed in {formatRelativeTime(s.createdAt)} + + +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/lib/user-agent.ts b/apps/web/src/lib/user-agent.ts new file mode 100644 index 0000000..2acf894 --- /dev/null +++ b/apps/web/src/lib/user-agent.ts @@ -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; +} diff --git a/apps/web/src/server/session.ts b/apps/web/src/server/session.ts index be6720d..a68f0a9 100644 --- a/apps/web/src/server/session.ts +++ b/apps/web/src/server/session.ts @@ -61,5 +61,6 @@ export const resolveSession: SessionResolver = async (req: Request): Promise { + 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(); + }); +}); diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts index 4277015..829cce1 100644 --- a/packages/api/src/context.ts +++ b/packages/api/src/context.ts @@ -17,6 +17,16 @@ export interface Session { phone: string | null; /** Only present for pros. Gates the procedures that require a live, verified pro. */ 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; } /** diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts index fb313f9..48c30c2 100644 --- a/packages/api/src/routers/user.ts +++ b/packages/api/src/routers/user.ts @@ -364,7 +364,14 @@ export const userRouter = router({ .orderBy(desc(schema.sessions.createdAt)); // 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, + })); }), /** diff --git a/packages/db/src/migrate-assets.ts b/packages/db/src/migrate-assets.ts index 429279f..1caaeb5 100644 --- a/packages/db/src/migrate-assets.ts +++ b/packages/db/src/migrate-assets.ts @@ -163,7 +163,29 @@ async function main() { 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 = ( await db.select({ url: schema.proMedia.url }).from(schema.proMedia) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 9f2d09a..b9adaaa 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -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 }, ]; -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. @@ -370,8 +385,11 @@ async function main() { const clientRows = await db .insert(schema.users) .values( - CLIENTS.map((name, i) => ({ - name, + CLIENTS.map((c, i) => ({ + 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`, /** * The first client gets the dev-login number (see web/src/server/dev-login.ts).