a18hvcryt: Delete the signed-in person's own post from its menu
This commit is contained in:
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user