fix(invites): build share URL on the client, not the server
server/invites.ts derived the share URL from c.req.url — i.e., from the API request's host. In production the API and SPA share an origin so this happened to work; in dev where the SPA runs on :5173 and the API on :3000, the generated link pointed at the API (http://localhost:3000/invite/<token>) which serves nothing. Fix: the server no longer returns a `url` field. The token is the canonical artefact; the SPA builds the share link itself via `${window.location.origin}/invite/${token}`, which is always the right origin regardless of split-process dev or single-process prod. - shared/types.ts: InviteEntry.url removed - server/invites.ts: drop originOf() and the URL field in toEntry() - frontend Profile.svelte: new inviteUrl() helper; the displayed <code> and the clipboard payload both use it - tests/social.test.ts: assertion checks token shape instead of the URL field 93 tests still pass.
This commit is contained in:
parent
9b825bfe1d
commit
e64d5450f8
4 changed files with 27 additions and 16 deletions
|
|
@ -167,13 +167,22 @@
|
|||
}
|
||||
}
|
||||
|
||||
/** Build the shareable invite URL using the SPA's own origin. The server
|
||||
* used to compute this but in dev that yielded the API host (port 3000),
|
||||
* not the SPA host (port 5173). The token is the canonical artefact;
|
||||
* the URL is just a presentation concern the SPA owns. */
|
||||
function inviteUrl(inv: InviteEntry): string {
|
||||
return `${window.location.origin}/invite/${inv.token}`;
|
||||
}
|
||||
|
||||
async function copyInviteUrl(inv: InviteEntry) {
|
||||
const url = inviteUrl(inv);
|
||||
try {
|
||||
await navigator.clipboard.writeText(inv.url);
|
||||
await navigator.clipboard.writeText(url);
|
||||
copiedToken = inv.token;
|
||||
setTimeout(() => { copiedToken = null; }, 1500);
|
||||
} catch {
|
||||
window.prompt('Kopier denne lenken:', inv.url);
|
||||
window.prompt('Kopier denne lenken:', url);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -372,7 +381,7 @@
|
|||
{#each invites as inv (inv.token)}
|
||||
<article class="card" style="margin-top: 0.5rem; {inv.claimed_at ? 'opacity: 0.7;' : ''}">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<code style="font-size: 0.8rem; word-break: break-all;">{inv.url}</code>
|
||||
<code style="font-size: 0.8rem; word-break: break-all;">{inviteUrl(inv)}</code>
|
||||
<span class="muted" style="white-space: nowrap;">{formatDate(inv.created_at)}</span>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 0.5rem;">
|
||||
|
|
|
|||
|
|
@ -35,19 +35,22 @@ interface InviteRow {
|
|||
claimed_email: string | null;
|
||||
}
|
||||
|
||||
function toEntry(row: InviteRow, origin: string): InviteEntry {
|
||||
function toEntry(row: InviteRow): InviteEntry {
|
||||
let claimedByDisplay: string | null = null;
|
||||
if (row.claimed_at && row.claimed_email) {
|
||||
if (row.claimed_display_name && row.claimed_display_name.trim()) {
|
||||
claimedByDisplay = row.claimed_display_name;
|
||||
} else {
|
||||
// No display name — fall back to the email prefix. Acceptable here
|
||||
// because the claimed-by info is only ever shown to the INVITER (who
|
||||
// shared the link in the first place, so they presumably know who got
|
||||
// it). Don't surface email anywhere public.
|
||||
const at = row.claimed_email.indexOf('@');
|
||||
claimedByDisplay = at > 0 ? row.claimed_email.slice(0, at) : row.claimed_email;
|
||||
}
|
||||
}
|
||||
return {
|
||||
token: row.token,
|
||||
url: `${origin}/invite/${row.token}`,
|
||||
created_at: row.created_at,
|
||||
claimed_at: row.claimed_at,
|
||||
claimed_by_display: claimedByDisplay,
|
||||
|
|
@ -75,11 +78,6 @@ export function claimInvite(token: string, claimerUserId: string): string | null
|
|||
return row.inviter_user_id;
|
||||
}
|
||||
|
||||
function originOf(c: { req: { url: string } }): string {
|
||||
const u = new URL(c.req.url);
|
||||
return `${u.protocol}//${u.host}`;
|
||||
}
|
||||
|
||||
// --- GET /api/invites -------------------------------------------------------
|
||||
// List the caller's invites (active first, then claimed).
|
||||
invitesRoutes.get('/', requireAuth, (c) => {
|
||||
|
|
@ -96,7 +94,7 @@ invitesRoutes.get('/', requireAuth, (c) => {
|
|||
ORDER BY (i.claimed_at IS NOT NULL) ASC, i.created_at DESC
|
||||
`)
|
||||
.all(userId) as InviteRow[];
|
||||
return c.json(rows.map((r) => toEntry(r, originOf(c))));
|
||||
return c.json(rows.map(toEntry));
|
||||
});
|
||||
|
||||
// --- POST /api/invites ------------------------------------------------------
|
||||
|
|
@ -115,7 +113,7 @@ invitesRoutes.post('/', requireAuth, (c) => {
|
|||
FROM invites i WHERE i.token = ?
|
||||
`)
|
||||
.get(token) as InviteRow;
|
||||
return c.json(toEntry(row, originOf(c)), 201);
|
||||
return c.json(toEntry(row), 201);
|
||||
});
|
||||
|
||||
// --- DELETE /api/invites/:token ---------------------------------------------
|
||||
|
|
|
|||
|
|
@ -42,12 +42,14 @@ export interface SettingsUpdateRequest {
|
|||
|
||||
// --- Invites ---------------------------------------------------------------
|
||||
export interface InviteEntry {
|
||||
/** The token itself. The shareable URL is built client-side as
|
||||
* `${window.location.origin}/invite/${token}` — server-side URL
|
||||
* construction would point at the API host in split-process dev
|
||||
* environments. */
|
||||
token: string;
|
||||
/** Absolute URL the inviter shares: <origin>/invite/<token>. */
|
||||
url: string;
|
||||
created_at: number;
|
||||
claimed_at: number | null;
|
||||
/** Display name (or email prefix) of the user who claimed it, if any. */
|
||||
/** Display name (or username) of the user who claimed it, if any. */
|
||||
claimed_by_display: string | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -119,7 +119,9 @@ describe('invites', () => {
|
|||
cookie: inviter.cookie, expect: 201,
|
||||
});
|
||||
expect(inv.token).toBeTruthy();
|
||||
expect(inv.url).toMatch(/\/invite\/[A-Za-z0-9_-]+/);
|
||||
// URL is built client-side from window.location.origin + the token, so
|
||||
// the server response no longer carries one. Just verify token shape.
|
||||
expect(inv.token).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(inv.claimed_at).toBeNull();
|
||||
|
||||
// A new user signs up with that invite token.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue