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

@ -5,7 +5,8 @@
import { session } from '../lib/session.svelte';
import { downloadExport } from '../lib/export';
import FriendsPanel from './FriendsPanel.svelte';
import type { InviteEntry } from '../../../shared/types';
import type { InviteEntry, UserLink } from '../../../shared/types';
import { USER_LINK_LIMITS } from '../../../shared/types';
interface Props {
onDone: () => void;
@ -61,6 +62,71 @@
let pwSaving = $state(false);
let pwChanged = $state(false);
// --- External profile links ----------------------------------------------
// svelte-ignore state_referenced_locally
let linkRows: { label: string; url: string }[] = $state(
session.user?.links?.map((l) => ({ label: l.label, url: l.url })) ?? [],
);
let linksSaving = $state(false);
let linksError: string | null = $state(null);
let linksSaved = $state(false);
function addLinkRow() {
if (linkRows.length >= USER_LINK_LIMITS.maxPerUser) return;
linkRows = [...linkRows, { label: '', url: '' }];
}
function removeLinkRow(i: number) {
linkRows = linkRows.filter((_, idx) => idx !== i);
}
/** Map a server error code from the links endpoint to a friendly message. */
function linkErrorMessage(code: string): string {
switch (code) {
case 'too_many_links':
return `Du kan ha maks ${USER_LINK_LIMITS.maxPerUser} lenker.`;
case 'link_label_required':
return 'Hver lenke trenger en kort etikett.';
case 'link_label_too_long':
return `Etikett kan være maks ${USER_LINK_LIMITS.labelMax} tegn.`;
case 'link_url_required':
return 'Hver lenke trenger en adresse.';
case 'link_url_too_long':
return 'Adressen er for lang.';
case 'link_url_malformed':
return 'En av adressene er ikke en gyldig URL.';
case 'link_url_bad_protocol':
return 'Adresser må starte med http:// eller https://.';
default:
return 'Lagring feilet.';
}
}
async function saveLinks() {
linksError = null;
linksSaved = false;
linksSaving = true;
try {
// Drop empty rows so users can leave half-typed entries without a
// server rejection — they were probably about to delete that row anyway.
const cleaned = linkRows
.map((l) => ({ label: l.label.trim(), url: l.url.trim() }))
.filter((l) => l.label || l.url);
const next = await api.updateProfile({ links: cleaned });
if (session.user) session.user.links = next.links;
linkRows = next.links.map((l: UserLink) => ({ label: l.label, url: l.url }));
linksSaved = true;
} catch (err) {
if (err instanceof ApiError && err.status === 400) {
const detail = err.detail as { error?: string } | null;
linksError = linkErrorMessage(detail?.error ?? 'generic');
} else {
linksError = 'Lagring feilet.';
}
} finally {
linksSaving = false;
}
}
// --- Invites -------------------------------------------------------------
let invites: InviteEntry[] = $state([]);
let invitesError: string | null = $state(null);
@ -219,6 +285,54 @@
<FriendsPanel />
<section class="card" aria-labelledby="links-h">
<h3 id="links-h">Eksterne lenker</h3>
<p class="muted">
Legg til opptil {USER_LINK_LIMITS.maxPerUser} lenker — sosiale profiler,
blogg, hva som helst. De vises på den offentlige listen din (når du har
skrudd den på).
</p>
{#each linkRows as link, i (i)}
<div class="row" style="margin-bottom: 0.5rem; align-items: stretch;">
<input
type="text"
placeholder="Etikett (f.eks. «Mastodon»)"
maxlength={USER_LINK_LIMITS.labelMax}
bind:value={linkRows[i].label}
style="flex: 1 1 35%; min-width: 9rem;"
aria-label={`Etikett for lenke ${i + 1}`}
/>
<input
type="url"
placeholder="https://…"
maxlength={USER_LINK_LIMITS.urlMax}
bind:value={linkRows[i].url}
style="flex: 2 1 50%; min-width: 12rem;"
aria-label={`URL for lenke ${i + 1}`}
/>
<button class="danger" type="button"
onclick={() => removeLinkRow(i)}
aria-label={`Fjern lenke ${i + 1}`}>
Fjern
</button>
</div>
{/each}
{#if linkRows.length < USER_LINK_LIMITS.maxPerUser}
<button type="button" onclick={addLinkRow}>+ Legg til lenke</button>
{/if}
{#if linksError}<p class="error" role="alert">{linksError}</p>{/if}
{#if linksSaved}<p class="muted" role="status">Lagret.</p>{/if}
<div class="row" style="margin-top: 0.75rem;">
<button class="primary" type="button" onclick={saveLinks} disabled={linksSaving}>
{linksSaving ? 'Lagrer …' : 'Lagre lenker'}
</button>
</div>
</section>
<section class="card" aria-labelledby="exp-h">
<h3 id="exp-h">Eksporter</h3>
<p class="muted">

View file

@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { api, ApiError } from '../lib/api';
import ActivityRow from './ActivityRow.svelte';
import type { ActivityPublic } from '../../../shared/types';
import type { ActivityPublic, UserLink } from '../../../shared/types';
interface Props {
username: string;
@ -11,6 +11,7 @@
let { username, onBack }: Props = $props();
let displayName: string | null = $state(null);
let links: UserLink[] = $state([]);
let activities: ActivityPublic[] = $state([]);
let loading = $state(true);
let notFound = $state(false);
@ -20,6 +21,7 @@
try {
const res = await api.publicList(username);
displayName = res.display_name;
links = res.links;
activities = res.activities;
} catch (err) {
if (err instanceof ApiError && err.status === 404) notFound = true;
@ -51,6 +53,21 @@
{:else}
<h2 style="font-size: 1.75rem; margin-top: 0.5rem;">{displayName?.trim() || username}</h2>
<p class="muted">Offentlige vinteraktiviteter</p>
{#if links.length}
<!-- rel="noopener" because we don't want the opened tab to be able to
script into our window via opener; "ugc" tells search engines this
is user-generated content (mild SEO hygiene + spam signal). -->
<p class="row" style="gap: 0.5rem; margin: 0.5rem 0 1rem;">
{#each links as link (link.id)}
<a href={link.url} target="_blank" rel="noopener noreferrer ugc"
class="tag" style="text-decoration: none; color: inherit;">
{link.label}
</a>
{/each}
</p>
{/if}
{#if !activities.length}
<p class="muted">Ingen offentlige aktiviteter ennå.</p>
{/if}