a18hvcryt: Delete the signed-in person's own post from its menu
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { SESSION_COOKIE } from "@/lib/cookie";
|
||||||
|
import { deletePost } from "@/lib/post-delete";
|
||||||
|
|
||||||
|
// DELETE /api/posts/:id -> feed DELETE /api/v1/posts/{id}
|
||||||
|
// 401 { error: "sign_in" } without a session. Returns { ok: true } on success.
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||||
|
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const result = await deletePost(sessionId, id);
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json({ error: result.error }, { status: result.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -88,6 +88,7 @@ export async function GET(req: NextRequest) {
|
|||||||
name: session.person.name,
|
name: session.person.name,
|
||||||
email: session.person.email,
|
email: session.person.email,
|
||||||
pictureUrl: session.person.pictureUrl,
|
pictureUrl: session.person.pictureUrl,
|
||||||
|
feedUserId: session.person.feedUserId ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,3 +61,11 @@ button:disabled { opacity: .5; cursor: not-allowed; }
|
|||||||
.comment-form button { margin: 0; }
|
.comment-form button { margin: 0; }
|
||||||
.link-btn { background: transparent; color: inherit; text-decoration: underline; padding: 0; margin: 0; font-size: inherit; width: auto; }
|
.link-btn { background: transparent; color: inherit; text-decoration: underline; padding: 0; margin: 0; font-size: inherit; width: auto; }
|
||||||
.comments-toggle { background: transparent; color: var(--grey); padding: 4px 8px; margin: 0; font-size: 14px; font-weight: 400; width: auto; }
|
.comments-toggle { background: transparent; color: var(--grey); padding: 4px 8px; margin: 0; font-size: 14px; font-weight: 400; width: auto; }
|
||||||
|
|
||||||
|
.post-menu { position: relative; margin-left: auto; }
|
||||||
|
.menu-btn { margin: 0; padding: 2px 10px; background: transparent; color: var(--grey); border-radius: 8px; font-size: 18px; line-height: 1; letter-spacing: 1px; }
|
||||||
|
.menu { position: absolute; right: 0; top: 100%; z-index: 5; display: flex; flex-direction: column; gap: 2px; min-width: 150px; padding: 6px; background: #fff; border: 1px solid #e3e3e3; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.12); }
|
||||||
|
.menu button { margin: 0; text-align: left; background: transparent; color: inherit; padding: 6px 8px; border-radius: 6px; font-size: 14px; font-weight: 400; }
|
||||||
|
.menu button:hover { background: #f4f4f4; }
|
||||||
|
.menu .menu-danger { color: #a8141b; }
|
||||||
|
.menu-ask { font-size: 13px; color: var(--grey); padding: 4px 8px; }
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ export function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (state.kind === "out") {
|
if (state.kind === "out") {
|
||||||
return <SignInForm onSignedIn={(person) => setState({ kind: "in", person })} />;
|
return <SignInForm onSignedIn={(person) => {
|
||||||
|
setState({ kind: "in", person });
|
||||||
|
// POST /api/session keeps its shape; the feed user id comes from GET.
|
||||||
|
void loadSession().then((full) => {
|
||||||
|
if (full) setState({ kind: "in", person: full });
|
||||||
|
});
|
||||||
|
}} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
@@ -52,7 +58,7 @@ export function App() {
|
|||||||
{signingOut ? "Signing out..." : "Sign out"}
|
{signingOut ? "Signing out..." : "Sign out"}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<Feed onSignedOut={() => setState({ kind: "out" })} />
|
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-1
@@ -5,11 +5,12 @@ import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
|
|||||||
import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
|
import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
|
||||||
import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model";
|
import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model";
|
||||||
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
||||||
|
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
||||||
import { FeedView, type FeedStatus } from "./FeedView";
|
import { FeedView, type FeedStatus } from "./FeedView";
|
||||||
|
|
||||||
const POLL_MS = 45_000;
|
const POLL_MS = 45_000;
|
||||||
|
|
||||||
export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
|
export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; feedUserId?: string | null }) {
|
||||||
const [posts, setPosts] = useState<CardPost[]>([]);
|
const [posts, setPosts] = useState<CardPost[]>([]);
|
||||||
const [status, setStatus] = useState<FeedStatus>("loading");
|
const [status, setStatus] = useState<FeedStatus>("loading");
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
@@ -18,6 +19,8 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
|
|||||||
const [pending, setPending] = useState<CardPost[]>([]);
|
const [pending, setPending] = useState<CardPost[]>([]);
|
||||||
const [likeBusy, setLikeBusy] = useState<ReadonlySet<string>>(new Set());
|
const [likeBusy, setLikeBusy] = useState<ReadonlySet<string>>(new Set());
|
||||||
const [likeErrors, setLikeErrors] = useState<ReadonlySet<string>>(new Set());
|
const [likeErrors, setLikeErrors] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
const [deleteErrors, setDeleteErrors] = useState<ReadonlySet<string>>(new Set());
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [posting, setPosting] = useState(false);
|
const [posting, setPosting] = useState(false);
|
||||||
const [postFailed, setPostFailed] = useState(false);
|
const [postFailed, setPostFailed] = useState(false);
|
||||||
@@ -156,6 +159,32 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
|
|||||||
[fail],
|
[fail],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const removePost = useCallback(
|
||||||
|
async (post: CardPost) => {
|
||||||
|
const mark = (set: ReadonlySet<string>, on: boolean) => {
|
||||||
|
const next = new Set(set);
|
||||||
|
if (on) next.add(post.key);
|
||||||
|
else next.delete(post.key);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
setDeleteBusy((s) => mark(s, true));
|
||||||
|
setDeleteErrors((s) => mark(s, false));
|
||||||
|
try {
|
||||||
|
// Removes the card only after the feed answered 200.
|
||||||
|
if (!post.id) throw new DeleteError(false);
|
||||||
|
await sendDelete(post.id);
|
||||||
|
setPosts((cur) => cur.filter((p) => p.key !== post.key));
|
||||||
|
setPending((cur) => cur.filter((p) => p.key !== post.key));
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DeleteError && e.signedOut) onSignedOut();
|
||||||
|
setDeleteErrors((s) => mark(s, true));
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy((s) => mark(s, false));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSignedOut],
|
||||||
|
);
|
||||||
|
|
||||||
const publish = useCallback(async () => {
|
const publish = useCallback(async () => {
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
@@ -199,6 +228,10 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
|
|||||||
onToggleLike={(p) => void toggleLike(p)}
|
onToggleLike={(p) => void toggleLike(p)}
|
||||||
likeBusy={likeBusy}
|
likeBusy={likeBusy}
|
||||||
likeErrors={likeErrors}
|
likeErrors={likeErrors}
|
||||||
|
feedUserId={feedUserId}
|
||||||
|
onDelete={(p) => void removePost(p)}
|
||||||
|
deleteBusy={deleteBusy}
|
||||||
|
deleteErrors={deleteErrors}
|
||||||
onSignedOut={onSignedOut}
|
onSignedOut={onSignedOut}
|
||||||
composer={{ value: draft, busy: posting, error: postFailed, onChange: changeDraft, onSubmit: () => void publish() }}
|
composer={{ value: draft, busy: posting, error: postFailed, onChange: changeDraft, onSubmit: () => void publish() }}
|
||||||
sentinelRef={sentinel}
|
sentinelRef={sentinel}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Ref } from "react";
|
import type { Ref } from "react";
|
||||||
import type { CardPost } from "@/lib/feed-model";
|
import { canDelete, type CardPost } from "@/lib/feed-model";
|
||||||
import { Composer, type ComposerProps } from "./Composer";
|
import { Composer, type ComposerProps } from "./Composer";
|
||||||
import { PostCard } from "./PostCard";
|
import { PostCard } from "./PostCard";
|
||||||
|
|
||||||
@@ -17,6 +17,11 @@ export interface FeedViewProps {
|
|||||||
likeBusy?: ReadonlySet<string>;
|
likeBusy?: ReadonlySet<string>;
|
||||||
likeErrors?: ReadonlySet<string>;
|
likeErrors?: ReadonlySet<string>;
|
||||||
onSignedOut?: () => void;
|
onSignedOut?: () => void;
|
||||||
|
/** The signed-in person's feed user id; delete is offered only on their own posts. */
|
||||||
|
feedUserId?: string | null;
|
||||||
|
onDelete?: (post: CardPost) => void;
|
||||||
|
deleteBusy?: ReadonlySet<string>;
|
||||||
|
deleteErrors?: ReadonlySet<string>;
|
||||||
composer?: ComposerProps;
|
composer?: ComposerProps;
|
||||||
sentinelRef?: Ref<HTMLDivElement>;
|
sentinelRef?: Ref<HTMLDivElement>;
|
||||||
now?: number;
|
now?: number;
|
||||||
@@ -65,6 +70,10 @@ export function FeedView(p: FeedViewProps) {
|
|||||||
likeBusy={p.likeBusy?.has(post.key)}
|
likeBusy={p.likeBusy?.has(post.key)}
|
||||||
likeError={p.likeErrors?.has(post.key)}
|
likeError={p.likeErrors?.has(post.key)}
|
||||||
onSignedOut={p.onSignedOut}
|
onSignedOut={p.onSignedOut}
|
||||||
|
canDelete={canDelete(post, p.feedUserId)}
|
||||||
|
onDelete={p.onDelete}
|
||||||
|
deleteBusy={p.deleteBusy?.has(post.key)}
|
||||||
|
deleteError={p.deleteErrors?.has(post.key)}
|
||||||
/>)
|
/>)
|
||||||
)}
|
)}
|
||||||
{p.loadMoreFailed ? (
|
{p.loadMoreFailed ? (
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export interface PostCardProps {
|
|||||||
likeBusy?: boolean;
|
likeBusy?: boolean;
|
||||||
likeError?: boolean;
|
likeError?: boolean;
|
||||||
onSignedOut?: () => void;
|
onSignedOut?: () => void;
|
||||||
|
/** True only on the signed-in person's own post. */
|
||||||
|
canDelete?: boolean;
|
||||||
|
onDelete?: (post: CardPost) => void;
|
||||||
|
deleteBusy?: boolean;
|
||||||
|
deleteError?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Heart({ filled }: { filled: boolean }) {
|
function Heart({ filled }: { filled: boolean }) {
|
||||||
@@ -24,8 +29,10 @@ function Heart({ filled }: { filled: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut }: PostCardProps) {
|
export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [menu, setMenu] = useState(false);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
return (
|
return (
|
||||||
<article className="post" data-testid="post-card">
|
<article className="post" data-testid="post-card">
|
||||||
<header className="post-head">
|
<header className="post-head">
|
||||||
@@ -41,7 +48,43 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
|
|||||||
<strong>{post.authorName}</strong>
|
<strong>{post.authorName}</strong>
|
||||||
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
|
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
|
||||||
</div>
|
</div>
|
||||||
|
{canDelete && onDelete ? (
|
||||||
|
<div className="post-menu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="menu-btn"
|
||||||
|
aria-label="More actions"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menu}
|
||||||
|
disabled={deleteBusy}
|
||||||
|
onClick={() => { setMenu((m) => !m); setConfirming(false); }}
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</button>
|
||||||
|
{menu ? (
|
||||||
|
<div className="menu" role="menu">
|
||||||
|
{confirming ? (
|
||||||
|
<>
|
||||||
|
<span className="menu-ask">Delete this post?</span>
|
||||||
|
<button type="button" role="menuitem" className="menu-danger" disabled={deleteBusy}
|
||||||
|
onClick={() => { setMenu(false); setConfirming(false); onDelete(post); }}>
|
||||||
|
Confirm delete
|
||||||
|
</button>
|
||||||
|
<button type="button" role="menuitem" onClick={() => { setConfirming(false); setMenu(false); }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button type="button" role="menuitem" className="menu-danger" onClick={() => setConfirming(true)}>
|
||||||
|
Delete post
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
{deleteError ? <p className="like-error" role="alert">Could not delete the post.</p> : null}
|
||||||
{post.text ? <p className="post-text">{post.text}</p> : null}
|
{post.text ? <p className="post-text">{post.text}</p> : null}
|
||||||
{post.media.map((m, i) =>
|
{post.media.map((m, i) =>
|
||||||
m.kind === "video" && m.hlsUrl ? (
|
m.kind === "video" && m.hlsUrl ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Browser-side calls to this app's own /api/session. No token, password or
|
// 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.
|
// 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 WRONG_CREDENTIALS = "Email or password is incorrect.";
|
||||||
export const GENERIC_FAILURE = "Could not sign you in. Try again.";
|
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 : "",
|
name: typeof r.name === "string" ? r.name : "",
|
||||||
email: r.email,
|
email: r.email,
|
||||||
pictureUrl: typeof r.pictureUrl === "string" ? r.pictureUrl : null,
|
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 {
|
export interface CardPost {
|
||||||
key: string;
|
key: string;
|
||||||
id: string | null;
|
id: string | null;
|
||||||
|
/** The author's id on the feed, for matching against the signed-in feed user id. */
|
||||||
|
authorId: string | null;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
authorPictureUrl: string | null;
|
authorPictureUrl: string | null;
|
||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
@@ -95,6 +97,8 @@ export function toCardPost(item: unknown, index = 0): CardPost | null {
|
|||||||
return {
|
return {
|
||||||
key: id ?? `${publishedAt ?? "unknown"}#${index}`,
|
key: id ?? `${publishedAt ?? "unknown"}#${index}`,
|
||||||
id,
|
id,
|
||||||
|
authorId:
|
||||||
|
typeof author.id === "string" || typeof author.id === "number" ? String(author.id) : null,
|
||||||
authorName: authorName(author),
|
authorName: authorName(author),
|
||||||
authorPictureUrl: str(author.profilePictureUrl),
|
authorPictureUrl: str(author.profilePictureUrl),
|
||||||
publishedAt,
|
publishedAt,
|
||||||
@@ -151,3 +155,8 @@ export function relativeTime(publishedAt: string | null, now: number = Date.now(
|
|||||||
if (d < 7) return `${d}d ago`;
|
if (d < 7) return `${d}d ago`;
|
||||||
return new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
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";
|
import type { SessionPerson } from "./session-store";
|
||||||
|
|
||||||
// Talks to the Minnect profiles host to turn an email + password into the
|
// 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.
|
* Runs the three-call sign-in: login -> auth/me -> feed-token exchange.
|
||||||
* Returns the person to show and the tokens to store server-side.
|
* Returns the person to show and the tokens to store server-side.
|
||||||
@@ -278,8 +295,12 @@ export async function loginToMinnect(
|
|||||||
options,
|
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 {
|
return {
|
||||||
person: { userId, name, email: personEmail, pictureUrl },
|
person: { userId, name, email: personEmail, pictureUrl, feedUserId },
|
||||||
tokens: {
|
tokens: {
|
||||||
profileAccessToken,
|
profileAccessToken,
|
||||||
profileAccessTokenExpiresAt,
|
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;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
pictureUrl: string | null;
|
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 {
|
export interface SessionData {
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { DELETE } from "@/app/api/posts/[id]/route";
|
||||||
|
import { GET as getSession } from "@/app/api/session/route";
|
||||||
|
import { FeedView } from "@/components/FeedView";
|
||||||
|
import { toCardPost, toCardPosts, canDelete } from "@/lib/feed-model";
|
||||||
|
import { DeleteError, deleteAndRemove, sendDelete } from "@/lib/delete-client";
|
||||||
|
import { loginToMinnect } from "@/lib/minnect";
|
||||||
|
import { createSession } from "@/lib/session-store";
|
||||||
|
|
||||||
|
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||||
|
const item = (id: string, authorId: string) => ({
|
||||||
|
type: "post", publishedAt: "2026-10-19T10:00:00Z",
|
||||||
|
post: { id, content: `text ${id}`, author: { id: authorId, displayName: "X" } },
|
||||||
|
});
|
||||||
|
const posts = () => toCardPosts([item("mine", "F1"), item("theirs", "F2")]);
|
||||||
|
|
||||||
|
function view(feedUserId: string | null) {
|
||||||
|
return renderToStaticMarkup(createElement(FeedView, {
|
||||||
|
posts: posts(), status: "ready", loadingMore: false, loadMoreFailed: false, newCount: 0,
|
||||||
|
onRetry: () => {}, onShowNew: () => {}, feedUserId, onDelete: () => {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(feedUserId: string | null = "F1") {
|
||||||
|
return createSession({
|
||||||
|
profileAccessToken: "p", profileAccessTokenExpiresAt: null, profileRefreshToken: "r",
|
||||||
|
feedAccessToken: "T", feedAccessTokenExpiresAt: null,
|
||||||
|
person: { userId: "u", name: "A", email: "a@b.co", pictureUrl: null, feedUserId }, createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFetch<T>(fn: typeof fetch, run: () => Promise<T>) {
|
||||||
|
const orig = globalThis.fetch;
|
||||||
|
globalThis.fetch = fn;
|
||||||
|
try { return await run(); } finally { globalThis.fetch = orig; }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("delete control visibility", () => {
|
||||||
|
it("own post has the menu; someone else's has none", () => {
|
||||||
|
const html = view("F1");
|
||||||
|
expect(html.match(/aria-label="More actions"/g)).toHaveLength(1);
|
||||||
|
const cards = html.split('data-testid="post-card"');
|
||||||
|
expect(cards[1]).toContain("More actions");
|
||||||
|
expect(cards[2]).not.toContain("More actions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no feed user id means no delete control anywhere", () => {
|
||||||
|
expect(view(null)).not.toContain("More actions");
|
||||||
|
expect(view("F9")).not.toContain("More actions");
|
||||||
|
expect(canDelete(toCardPost(item("a", "F1"))!, undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /api/posts/:id", () => {
|
||||||
|
it("401 sign_in without a session", async () => {
|
||||||
|
const res = await DELETE(new NextRequest("http://localhost/api/posts/p1", { method: "DELETE" }), ctx("p1"));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(await res.json()).toEqual({ error: "sign_in" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the feed DELETE and returns ok on 200", async () => {
|
||||||
|
const f = vi.fn(async () => new Response("{}", { status: 200 }));
|
||||||
|
const res = await withFetch(f as unknown as typeof fetch, () =>
|
||||||
|
DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: { cookie: `mfb_session=${session()}` } }), ctx("p1")));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit];
|
||||||
|
expect(new URL(url).pathname).toBe("/api/v1/posts/p1");
|
||||||
|
expect(init.method).toBe("DELETE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a feed failure is 502 delete_failed; a bad id never reaches the feed", async () => {
|
||||||
|
const h = { cookie: `mfb_session=${session()}` };
|
||||||
|
const bad = vi.fn(async () => new Response("{}", { status: 500 }));
|
||||||
|
const r = await withFetch(bad as unknown as typeof fetch, () =>
|
||||||
|
DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: h }), ctx("p1")));
|
||||||
|
expect([r.status, await r.json()]).toEqual([502, { error: "delete_failed" }]);
|
||||||
|
const f = vi.fn();
|
||||||
|
const r2 = await withFetch(f as unknown as typeof fetch, () =>
|
||||||
|
DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: h }), ctx("../admin/x")));
|
||||||
|
expect(r2.status).toBe(400);
|
||||||
|
expect(f).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removing the card", () => {
|
||||||
|
it("the card leaves the list after a 200", async () => {
|
||||||
|
const ok = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) as unknown as typeof fetch;
|
||||||
|
const list = posts();
|
||||||
|
const next = await deleteAndRemove(list, list[0], (id) => sendDelete(id, ok));
|
||||||
|
expect(next.map((p) => p.id)).toEqual(["theirs"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed delete throws and the caller keeps the card", async () => {
|
||||||
|
const fail = vi.fn(async () => new Response("{}", { status: 502 })) as unknown as typeof fetch;
|
||||||
|
const list = posts();
|
||||||
|
await expect(deleteAndRemove(list, list[0], (id) => sendDelete(id, fail))).rejects.toBeInstanceOf(DeleteError);
|
||||||
|
expect(list).toHaveLength(2);
|
||||||
|
const out = vi.fn(async () => new Response("{}", { status: 401 })) as unknown as typeof fetch;
|
||||||
|
await expect(sendDelete("mine", out)).rejects.toMatchObject({ signedOut: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("feed user id", () => {
|
||||||
|
it("GET /api/session exposes feedUserId and no token", async () => {
|
||||||
|
const res = await getSession(new NextRequest("http://localhost/api/session", { headers: { cookie: `mfb_session=${session("F1")}` } }));
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ name: "A", email: "a@b.co", pictureUrl: null, feedUserId: "F1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sign-in captures data.id from the feed auth/me", async () => {
|
||||||
|
const f = vi.fn(async (url: string) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.endsWith("/api/v4/auth/login")) return new Response(JSON.stringify({ data: { accessToken: "a", refreshToken: "r", expiresIn: 3600 } }), { status: 200 });
|
||||||
|
if (u.endsWith("/api/v4/auth/me")) return new Response(JSON.stringify({ data: { userId: "u1", email: "a@b.co", firstName: "A" } }), { status: 200 });
|
||||||
|
if (u.endsWith("/external-services/feed")) return new Response(JSON.stringify({ accessToken: "ft" }), { status: 200 });
|
||||||
|
if (u.endsWith("/api/v1/auth/me")) return new Response(JSON.stringify({ data: { id: "F1" } }), { status: 200 });
|
||||||
|
return new Response("{}", { status: 404 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
const r = await loginToMinnect("a@b.co", "pw", { fetchImpl: f });
|
||||||
|
expect(r.person.feedUserId).toBe("F1");
|
||||||
|
const down = vi.fn(async (url: string) => String(url).endsWith("/api/v1/auth/me") ? new Response("{}", { status: 500 }) : (f as unknown as (u: string) => Promise<Response>)(url)) as unknown as typeof fetch;
|
||||||
|
expect((await loginToMinnect("a@b.co", "pw", { fetchImpl: down })).person.feedUserId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user