a18hvcryt: Delete the signed-in person's own post from its menu

This commit is contained in:
2026-10-19 14:32:06 -06:00
parent 5a33546af2
commit 0861663bea
14 changed files with 383 additions and 8 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
// Browser-side calls to this app's own /api/session. No token, password or
// session id is stored or logged here; the session lives in an httpOnly cookie.
export type Person = { name: string; email: string; pictureUrl: string | null };
export type Person = { name: string; email: string; pictureUrl: string | null; feedUserId?: string };
export const WRONG_CREDENTIALS = "Email or password is incorrect.";
export const GENERIC_FAILURE = "Could not sign you in. Try again.";
@@ -16,6 +16,7 @@ function toPerson(body: unknown): Person | null {
name: typeof r.name === "string" ? r.name : "",
email: r.email,
pictureUrl: typeof r.pictureUrl === "string" ? r.pictureUrl : null,
...(typeof r.feedUserId === "string" && r.feedUserId ? { feedUserId: r.feedUserId } : {}),
};
}
+43
View File
@@ -0,0 +1,43 @@
import type { CardPost } from "./feed-model";
// Browser-side delete call.
export class DeleteError extends Error {
constructor(public readonly signedOut: boolean) {
super(signedOut ? "sign_in" : "delete_failed");
}
}
/** DELETE /api/posts/:id. Resolves on success; throws DeleteError otherwise. */
export async function sendDelete(postId: string, fetchImpl: typeof fetch = fetch): Promise<void> {
let res: Response;
try {
res = await fetchImpl(`/api/posts/${encodeURIComponent(postId)}`, {
method: "DELETE",
credentials: "same-origin",
});
} catch {
throw new DeleteError(false);
}
if (res.status === 401) throw new DeleteError(true);
if (!res.ok) throw new DeleteError(false);
}
/** The feed list after a successful delete: the card is gone. */
export function withoutPost(posts: CardPost[], key: string): CardPost[] {
return posts.filter((p) => p.key !== key);
}
/**
* Delete a post and return the new list. The card is removed only after the
* server answers 200; on any failure this throws and the caller keeps the list.
*/
export async function deleteAndRemove(
posts: CardPost[],
post: CardPost,
send: (id: string) => Promise<void> = sendDelete,
): Promise<CardPost[]> {
if (!post.id) throw new DeleteError(false);
await send(post.id);
return withoutPost(posts, post.key);
}
+9
View File
@@ -12,6 +12,8 @@ export interface CardMedia {
export interface CardPost {
key: string;
id: string | null;
/** The author's id on the feed, for matching against the signed-in feed user id. */
authorId: string | null;
authorName: string;
authorPictureUrl: string | null;
publishedAt: string | null;
@@ -95,6 +97,8 @@ export function toCardPost(item: unknown, index = 0): CardPost | null {
return {
key: id ?? `${publishedAt ?? "unknown"}#${index}`,
id,
authorId:
typeof author.id === "string" || typeof author.id === "number" ? String(author.id) : null,
authorName: authorName(author),
authorPictureUrl: str(author.profilePictureUrl),
publishedAt,
@@ -151,3 +155,8 @@ export function relativeTime(publishedAt: string | null, now: number = Date.now(
if (d < 7) return `${d}d ago`;
return new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
/** Delete is offered only on the signed-in person's own post. */
export function canDelete(post: CardPost, feedUserId: string | null | undefined): boolean {
return !!feedUserId && !!post.id && post.authorId !== null && post.authorId === feedUserId;
}
+23 -2
View File
@@ -1,4 +1,4 @@
import { PROFILES_BASE_URL } from "./env";
import { FEED_BASE_URL, PROFILES_BASE_URL } from "./env";
import type { SessionPerson } from "./session-store";
// Talks to the Minnect profiles host to turn an email + password into the
@@ -217,6 +217,23 @@ export async function revokeToken(
}
}
/** `GET /api/v1/auth/me` on the feed host. Returns data.id, or null on any failure. */
async function fetchFeedUserId(feedToken: string, doFetch: typeof fetch): Promise<string | null> {
try {
const res = await doFetch(`${FEED_BASE_URL}/api/v1/auth/me`, {
method: "GET",
headers: jsonHeaders(feedToken),
});
if (res.status !== 200) return null;
const id = asRecord(asRecord(await parseJson(res)).data).id;
if (typeof id === "string" && id.length > 0) return id;
if (typeof id === "number" && Number.isFinite(id)) return String(id);
return null;
} catch {
return null;
}
}
/**
* Runs the three-call sign-in: login -> auth/me -> feed-token exchange.
* Returns the person to show and the tokens to store server-side.
@@ -278,8 +295,12 @@ export async function loginToMinnect(
options,
);
// 4. The person's id on the feed, used to decide who may delete a post.
// Best-effort: without it no delete control is offered.
const feedUserId = await fetchFeedUserId(feedAccessToken, doFetch);
return {
person: { userId, name, email: personEmail, pictureUrl },
person: { userId, name, email: personEmail, pictureUrl, feedUserId },
tokens: {
profileAccessToken,
profileAccessTokenExpiresAt,
+52
View File
@@ -0,0 +1,52 @@
import { FEED_BASE_URL } from "./env";
import { ensureFreshSession, refreshSession } from "./session-refresh";
// Delete proxy: feed DELETE /api/v1/posts/{id} with the person's server-held
// feed token. The feed decides whether the caller may delete; this app also
// only offers the control on the person's own posts. One refresh-and-retry
// after a 401 token rejection.
export type DeleteResult =
| { ok: true }
| { ok: false; status: 400 | 401 | 502; error: "sign_in" | "bad_post" | "delete_failed" };
interface DeleteOptions {
fetchImpl?: typeof fetch;
now?: number;
}
const POST_ID = /^[A-Za-z0-9_-]{1,128}$/;
function call(token: string, postId: string, doFetch: typeof fetch) {
return doFetch(`${FEED_BASE_URL}/api/v1/posts/${encodeURIComponent(postId)}`, {
method: "DELETE",
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
});
}
export async function deletePost(
sessionId: string | undefined,
postId: string,
options: DeleteOptions = {},
): Promise<DeleteResult> {
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
const failed = { ok: false, status: 502, error: "delete_failed" } as const;
const doFetch = options.fetchImpl ?? fetch;
let session = await ensureFreshSession(sessionId, options);
if (!session || !sessionId) return signIn;
if (!POST_ID.test(postId)) return { ok: false, status: 400, error: "bad_post" };
try {
let res = await call(session.feedAccessToken, postId, doFetch);
if (res.status === 401) {
session = await refreshSession(sessionId, options);
if (!session) return signIn;
res = await call(session.feedAccessToken, postId, doFetch);
if (res.status === 401) return signIn;
}
return res.status === 200 ? { ok: true } : failed;
} catch {
return failed;
}
}
+2
View File
@@ -12,6 +12,8 @@ export interface SessionPerson {
name: string;
email: string;
pictureUrl: string | null;
/** The person's id on the feed (feed GET /api/v1/auth/me data.id); null when unknown. */
feedUserId?: string | null;
}
export interface SessionData {