External profile links (max 5 per user)

Users can attach up to 5 labelled URLs to their profile — social
handles, blog, anything. They're shown on /<username>/list (the
opt-in public list), behind target="_blank" rel="noopener noreferrer
ugc" anchors so the destination tab can't script back into our
window.

Schema:
  - user_links(id, user_id, label, url, position, created_at)
    UNIQUE(user_id, position) to keep ordering stable, ON DELETE
    CASCADE so user deletion sweeps links.

Wire:
  - UserLink type (id/label/url)
  - MeResponse.links: UserLink[]
  - PublicListResponse.links: UserLink[]
  - ProfileUpdateRequest.links?: { label, url }[]  — bulk replace
  - USER_LINK_LIMITS exported so frontend constraints match server

Validation (server/auth.ts):
  - Label 1-40 chars, trimmed
  - URL parseable + http:// or https:// only (no javascript:, data:,
    mailto:, etc.)
  - URL ≤ 500 chars
  - Max 5 links per user

Bulk replace semantics with up-front validation, then DELETE +
INSERT inside the same transaction as the user UPDATE. A username
UNIQUE violation rolls back the link changes too — no half-applied
state. Empty rows in the request are silently dropped so users
can leave half-typed entries without a server rejection.

Frontend Profile gets an "Eksterne lenker" section between the
FriendsPanel and the Eksporter section. Five label+URL row pairs
with add/remove buttons, save button, error → Bokmål mapping
(link_label_required, link_url_bad_protocol, etc.).

93 tests still pass; typecheck clean; build ok.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 16:20:04 +02:00
commit 9b825bfe1d
6 changed files with 258 additions and 11 deletions

View file

@ -94,6 +94,20 @@ export interface RecoveryCompleteRequest {
dek_pw_nonce: string;
}
/** A user's external profile link — social, blog, anything they want. */
export interface UserLink {
id: string;
label: string;
url: string;
}
/** Limits enforced server-side; surfaced so the client UI matches. */
export const USER_LINK_LIMITS = {
maxPerUser: 5,
labelMax: 40,
urlMax: 500,
} as const;
export interface MeResponse {
id: string;
email: string;
@ -102,6 +116,7 @@ export interface MeResponse {
is_admin: boolean;
username: string | null;
public_list_enabled: boolean;
links: UserLink[];
}
// --- Admin -----------------------------------------------------------------
@ -124,16 +139,21 @@ export interface AdminRoleUpdate {
}
export interface ProfileUpdateRequest {
// All optional — omit a field to leave it alone. Pass `null` to clear.
// All optional — omit a field to leave it alone. Pass `null` to clear
// display_name or username. The `links` field, when present, replaces
// the user's entire link list (bulk replace semantics — simpler than
// tracking per-link ids on the client).
display_name?: string | null;
username?: string | null;
public_list_enabled?: boolean;
links?: { label: string; url: string }[];
}
/** Response shape for GET /api/users/:username/list (opt-in public list). */
export interface PublicListResponse {
username: string;
display_name: string | null;
links: UserLink[];
activities: ActivityPublic[];
}