Skip to content

Commit c77022a

Browse files
committed
Fix deleted-user access hole: fail-closed UI and backend tombstone guard
1 parent 7eb622d commit c77022a

8 files changed

Lines changed: 80 additions & 15 deletions

File tree

functions/_lib/db.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,13 @@ const ensureSchema = async (env: Env): Promise<void> => {
134134
updated_at TEXT
135135
)`,
136136
),
137+
env.DB.prepare(
138+
`CREATE TABLE IF NOT EXISTS deleted_users (
139+
id TEXT PRIMARY KEY,
140+
deleted_at TEXT NOT NULL,
141+
deleted_by_user_id TEXT
142+
)`,
143+
),
137144
env.DB.prepare(
138145
`CREATE TABLE IF NOT EXISTS sites (
139146
id TEXT PRIMARY KEY,
@@ -369,6 +376,10 @@ export const ensureUser = async (
369376
tokenPayload?: Record<string, unknown>,
370377
): Promise<void> => {
371378
await ensureSchema(env);
379+
const deleted = await env.DB.prepare("SELECT id FROM deleted_users WHERE id = ? LIMIT 1").bind(userId).first<{ id: string }>();
380+
if (deleted?.id) {
381+
throw new Error("Account removed by admin");
382+
}
372383
const now = new Date().toISOString();
373384
const username = deriveDefaultName(userId, tokenPayload);
374385
const email = deriveDefaultEmail(userId, tokenPayload);
@@ -539,9 +550,19 @@ export const setUserApproval = async (
539550
return profile;
540551
};
541552

542-
export const deleteUser = async (env: Env, userId: string): Promise<void> => {
553+
export const deleteUser = async (env: Env, userId: string, actorUserId?: string): Promise<void> => {
543554
await ensureSchema(env);
544-
await env.DB.prepare("DELETE FROM users WHERE id = ?").bind(userId).run();
555+
const now = new Date().toISOString();
556+
await env.DB.batch([
557+
env.DB
558+
.prepare(
559+
`INSERT INTO deleted_users (id, deleted_at, deleted_by_user_id)
560+
VALUES (?, ?, ?)
561+
ON CONFLICT(id) DO UPDATE SET deleted_at = excluded.deleted_at, deleted_by_user_id = excluded.deleted_by_user_id`,
562+
)
563+
.bind(userId, now, actorUserId ?? null),
564+
env.DB.prepare("DELETE FROM users WHERE id = ?").bind(userId),
565+
]);
545566
};
546567

547568
export const listPendingApprovalUsers = async (

functions/api/changes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
2626
return withCors(request, json({ changes }));
2727
} catch (error) {
2828
const message = error instanceof Error ? error.message : String(error);
29-
const status = message.includes("pending approval") ? 403 : 500;
29+
const status = message.includes("pending approval") || message.includes("removed by admin") ? 403 : 500;
3030
return withCors(request, json({ error: message }, { status }));
3131
}
3232
};

functions/api/library.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
2323
);
2424
} catch (error) {
2525
const message = error instanceof Error ? error.message : String(error);
26-
const status = message.includes("pending approval") ? 403 : 500;
26+
const status = message.includes("pending approval") || message.includes("removed by admin") ? 403 : 500;
2727
return withCors(request, json({ error: message }, { status }));
2828
}
2929
};
@@ -53,7 +53,10 @@ export const onRequestPut: PagesFunction<Env> = async ({ request, env }) => {
5353
);
5454
} catch (error) {
5555
const message = error instanceof Error ? error.message : String(error);
56-
const status = message.includes("pending approval") ? 403 : 400;
56+
const status =
57+
message.includes("pending approval") || message.includes("removed by admin")
58+
? 403
59+
: 400;
5760
return withCors(request, json({ error: message }, { status }));
5861
}
5962
};

functions/api/me.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
2424
);
2525
} catch (error) {
2626
const message = error instanceof Error ? error.message : String(error);
27-
return withCors(request, json({ error: message }, { status: 401 }));
27+
const status = message.includes("removed by admin")
28+
? 403
29+
: message.includes("pending approval")
30+
? 403
31+
: 401;
32+
return withCors(request, json({ error: message }, { status }));
2833
}
2934
};
3035

@@ -46,7 +51,12 @@ export const onRequestPatch: PagesFunction<Env> = async ({ request, env }) => {
4651
return withCors(request, json({ user }));
4752
} catch (error) {
4853
const message = error instanceof Error ? error.message : String(error);
49-
const status = message.includes("required") || message.includes("valid") ? 400 : 500;
54+
const status =
55+
message.includes("removed by admin") || message.includes("pending approval")
56+
? 403
57+
: message.includes("required") || message.includes("valid")
58+
? 400
59+
: 500;
5060
return withCors(request, json({ error: message }, { status }));
5161
}
5262
};

functions/api/notifications.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
4242
);
4343
} catch (error) {
4444
const message = error instanceof Error ? error.message : String(error);
45-
const status = message.includes("pending approval") ? 403 : 500;
45+
const status = message.includes("pending approval") || message.includes("removed by admin") ? 403 : 500;
4646
return withCors(request, json({ error: message }, { status }));
4747
}
4848
};

functions/api/users.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
2020
return withCors(request, json({ users }));
2121
} catch (error) {
2222
const message = error instanceof Error ? error.message : String(error);
23-
const status = message.includes("pending approval") ? 403 : 500;
23+
const status = message.includes("pending approval") || message.includes("removed by admin") ? 403 : 500;
2424
return withCors(request, json({ error: message }, { status }));
2525
}
2626
};

functions/api/users/[id].ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export const onRequestGet: PagesFunction<Env> = async ({ request, env, params })
5151
return withCors(request, json({ user }));
5252
} catch (error) {
5353
const message = error instanceof Error ? error.message : String(error);
54-
const status = message.includes("pending approval") ? 403 : 500;
54+
const status = message.includes("pending approval") || message.includes("removed by admin") ? 403 : 500;
5555
return withCors(request, json({ error: message }, { status }));
5656
}
5757
};
@@ -118,7 +118,7 @@ export const onRequestPatch: PagesFunction<Env> = async ({ request, env, params
118118
const status =
119119
message.includes("required") || message.includes("valid")
120120
? 400
121-
: message.includes("pending approval")
121+
: message.includes("pending approval") || message.includes("removed by admin")
122122
? 403
123123
: 500;
124124
return withCors(request, json({ error: message }, { status }));
@@ -142,11 +142,11 @@ export const onRequestDelete: PagesFunction<Env> = async ({ request, env, params
142142
return withCors(request, json({ error: "Admin cannot delete own account." }, { status: 400 }));
143143
}
144144

145-
await deleteUser(env, targetId);
145+
await deleteUser(env, targetId, auth.userId);
146146
return withCors(request, json({ ok: true }));
147147
} catch (error) {
148148
const message = error instanceof Error ? error.message : String(error);
149-
const status = message.includes("pending approval") ? 403 : 500;
149+
const status = message.includes("pending approval") || message.includes("removed by admin") ? 403 : 500;
150150
return withCors(request, json({ error: message }, { status }));
151151
}
152152
};

src/components/AppShell.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function AppShell() {
1212
(state) => state.recommendAndFetchTerrainForCurrentArea,
1313
);
1414
const [isMapExpanded, setIsMapExpanded] = useState(false);
15-
const [accessState, setAccessState] = useState<"checking" | "granted" | "pending">("checking");
15+
const [accessState, setAccessState] = useState<"checking" | "granted" | "pending" | "locked">("checking");
1616

1717
useEffect(() => {
1818
if (srtmTilesCount > 0) return;
@@ -25,11 +25,21 @@ export function AppShell() {
2525
const me = await fetchMe();
2626
setAccessState(me.isAdmin || me.isApproved ? "granted" : "pending");
2727
} catch {
28-
setAccessState("granted");
28+
setAccessState("locked");
2929
}
3030
})();
3131
}, []);
3232

33+
if (accessState === "checking") {
34+
return (
35+
<main className="app-shell access-locked-shell">
36+
<section className="panel-section access-locked-panel">
37+
<h2>Checking access…</h2>
38+
</section>
39+
</main>
40+
);
41+
}
42+
3343
if (accessState === "pending") {
3444
return (
3545
<main className="app-shell access-locked-shell">
@@ -51,6 +61,27 @@ export function AppShell() {
5161
);
5262
}
5363

64+
if (accessState === "locked") {
65+
return (
66+
<main className="app-shell access-locked-shell">
67+
<section className="panel-section access-locked-panel">
68+
<h2>Access unavailable</h2>
69+
<p className="field-help">
70+
Your account session is valid, but this account is not available in LinkSim right now.
71+
</p>
72+
<p className="field-help">
73+
If your user was removed by an admin, ask for re-approval. Then sign out and sign in again.
74+
</p>
75+
<div className="chip-group">
76+
<button className="inline-action" onClick={() => (window.location.href = "/cdn-cgi/access/logout")} type="button">
77+
Sign Out
78+
</button>
79+
</div>
80+
</section>
81+
</main>
82+
);
83+
}
84+
5485
return (
5586
<main className={`app-shell ${isMapExpanded ? "is-map-expanded" : ""}`}>
5687
{!isMapExpanded ? <Sidebar /> : null}

0 commit comments

Comments
 (0)