Skip to content

Commit 3eb02b1

Browse files
committed
Add pending-user lock mode and access request note in profile flow
1 parent 76ef4ff commit 3eb02b1

8 files changed

Lines changed: 102 additions & 9 deletions

File tree

db/schema.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS users (
55
username TEXT,
66
email TEXT,
77
bio TEXT,
8+
access_request_note TEXT,
89
avatar_url TEXT,
910
is_admin INTEGER NOT NULL DEFAULT 0,
1011
is_approved INTEGER NOT NULL DEFAULT 0,

functions/_lib/db.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ const sanitizeBio = (value: unknown): string | null => {
5050
return bio.length <= 300 ? bio : bio.slice(0, 300);
5151
};
5252

53+
const sanitizeAccessRequestNote = (value: unknown): string | null => {
54+
if (typeof value !== "string") return null;
55+
const note = value.trim();
56+
return note.length <= 1200 ? note : note.slice(0, 1200);
57+
};
58+
5359
const sanitizeAvatar = (value: unknown): string | null => {
5460
if (typeof value !== "string") return null;
5561
const raw = value.trim();
@@ -111,6 +117,7 @@ const ensureSchema = async (env: Env): Promise<void> => {
111117
username TEXT,
112118
email TEXT,
113119
bio TEXT,
120+
access_request_note TEXT,
114121
avatar_url TEXT,
115122
is_admin INTEGER NOT NULL DEFAULT 0,
116123
is_approved INTEGER NOT NULL DEFAULT 0,
@@ -198,6 +205,7 @@ const ensureSchema = async (env: Env): Promise<void> => {
198205
!userNames.has("username") ? "ALTER TABLE users ADD COLUMN username TEXT" : "",
199206
!userNames.has("email") ? "ALTER TABLE users ADD COLUMN email TEXT" : "",
200207
!userNames.has("bio") ? "ALTER TABLE users ADD COLUMN bio TEXT" : "",
208+
!userNames.has("access_request_note") ? "ALTER TABLE users ADD COLUMN access_request_note TEXT" : "",
201209
!userNames.has("avatar_url") ? "ALTER TABLE users ADD COLUMN avatar_url TEXT" : "",
202210
!userNames.has("is_admin") ? "ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0" : "",
203211
!userNames.has("is_approved") ? "ALTER TABLE users ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 0" : "",
@@ -239,6 +247,7 @@ type UserRow = {
239247
username: string | null;
240248
email: string | null;
241249
bio: string | null;
250+
access_request_note: string | null;
242251
avatar_url: string | null;
243252
is_admin: number;
244253
is_approved: number;
@@ -253,6 +262,7 @@ const toUserProfile = (row: UserRow) => ({
253262
username: sanitizeName(row.username) ?? "User",
254263
email: sanitizeEmail(row.email) ?? "unknown@users.linksim.local",
255264
bio: row.bio ?? "",
265+
accessRequestNote: row.access_request_note ?? "",
256266
avatarUrl: row.avatar_url ?? "",
257267
isAdmin: row.is_admin === 1,
258268
isApproved: row.is_approved === 1,
@@ -266,7 +276,7 @@ const readUserRow = async (env: Env, userId: string): Promise<UserRow | null> =>
266276
await ensureSchema(env);
267277
return env.DB
268278
.prepare(
269-
"SELECT id, username, email, bio, avatar_url, is_admin, is_approved, approved_at, approved_by_user_id, created_at, updated_at FROM users WHERE id = ?",
279+
"SELECT id, username, email, bio, access_request_note, avatar_url, is_admin, is_approved, approved_at, approved_by_user_id, created_at, updated_at FROM users WHERE id = ?",
270280
)
271281
.bind(userId)
272282
.first<UserRow>();
@@ -278,7 +288,7 @@ const reconcileUserIdentityByEmail = async (env: Env, userId: string, email: str
278288

279289
const existing = await env.DB
280290
.prepare(
281-
`SELECT id, username, email, bio, avatar_url, is_admin, is_approved, approved_at, approved_by_user_id, created_at, updated_at
291+
`SELECT id, username, email, bio, access_request_note, avatar_url, is_admin, is_approved, approved_at, approved_by_user_id, created_at, updated_at
282292
FROM users
283293
WHERE lower(email) = lower(?) AND id <> ?
284294
ORDER BY is_admin DESC, is_approved DESC, created_at ASC
@@ -348,8 +358,8 @@ export const ensureUser = async (
348358

349359
await env.DB.prepare(
350360
`INSERT OR IGNORE INTO users
351-
(id, username, email, bio, avatar_url, is_admin, is_approved, approved_at, approved_by_user_id, created_at, updated_at)
352-
VALUES (?, ?, ?, '', '', ?, ?, ?, ?, ?, ?)`,
361+
(id, username, email, bio, access_request_note, avatar_url, is_admin, is_approved, approved_at, approved_by_user_id, created_at, updated_at)
362+
VALUES (?, ?, ?, '', '', '', ?, ?, ?, ?, ?, ?)`,
353363
)
354364
.bind(
355365
userId,
@@ -409,14 +419,24 @@ export const assertUserAccess = async (env: Env, userId: string) => {
409419
export const updateUserProfile = async (
410420
env: Env,
411421
userId: string,
412-
patch: { username?: unknown; email?: unknown; bio?: unknown; avatarUrl?: unknown },
422+
patch: {
423+
username?: unknown;
424+
email?: unknown;
425+
bio?: unknown;
426+
accessRequestNote?: unknown;
427+
avatarUrl?: unknown;
428+
},
413429
) => {
414430
const existing = await readUserRow(env, userId);
415431
if (!existing) throw new Error("User not found.");
416432

417433
const nextName = patch.username === undefined ? sanitizeName(existing.username) : sanitizeName(patch.username);
418434
const nextEmail = patch.email === undefined ? sanitizeEmail(existing.email) : sanitizeEmail(patch.email);
419435
const nextBio = patch.bio === undefined ? existing.bio ?? "" : sanitizeBio(patch.bio) ?? "";
436+
const nextAccessRequestNote =
437+
patch.accessRequestNote === undefined
438+
? existing.access_request_note ?? ""
439+
: sanitizeAccessRequestNote(patch.accessRequestNote) ?? "";
420440
const nextAvatar = patch.avatarUrl === undefined ? existing.avatar_url ?? "" : sanitizeAvatar(patch.avatarUrl);
421441

422442
if (!nextName) throw new Error("Name is required (2-80 chars).");
@@ -425,10 +445,18 @@ export const updateUserProfile = async (
425445

426446
await env.DB.prepare(
427447
`UPDATE users
428-
SET username = ?, email = ?, bio = ?, avatar_url = ?, updated_at = ?
448+
SET username = ?, email = ?, bio = ?, access_request_note = ?, avatar_url = ?, updated_at = ?
429449
WHERE id = ?`,
430450
)
431-
.bind(nextName, nextEmail, nextBio, nextAvatar ?? "", new Date().toISOString(), userId)
451+
.bind(
452+
nextName,
453+
nextEmail,
454+
nextBio,
455+
nextAccessRequestNote,
456+
nextAvatar ?? "",
457+
new Date().toISOString(),
458+
userId,
459+
)
432460
.run();
433461

434462
const profile = await fetchUserProfile(env, userId);
@@ -440,7 +468,7 @@ export const listUsers = async (env: Env) => {
440468
await ensureSchema(env);
441469
const rows = await env.DB
442470
.prepare(
443-
"SELECT id, username, email, bio, avatar_url, is_admin, is_approved, approved_at, approved_by_user_id, created_at, updated_at FROM users ORDER BY created_at DESC LIMIT 2000",
471+
"SELECT id, username, email, bio, access_request_note, avatar_url, is_admin, is_approved, approved_at, approved_by_user_id, created_at, updated_at FROM users ORDER BY created_at DESC LIMIT 2000",
444472
)
445473
.all<UserRow>();
446474
return rows.results.map(toUserProfile);

functions/api/me.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const onRequestPatch: PagesFunction<Env> = async ({ request, env }) => {
3939
username?: unknown;
4040
email?: unknown;
4141
bio?: unknown;
42+
accessRequestNote?: unknown;
4243
avatarUrl?: unknown;
4344
};
4445
const user = await updateUserProfile(env, auth.userId, body);

functions/api/users/[id].ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export const onRequestPatch: PagesFunction<Env> = async ({ request, env, params
7373
username?: unknown;
7474
email?: unknown;
7575
bio?: unknown;
76+
accessRequestNote?: unknown;
7677
avatarUrl?: unknown;
7778
isAdmin?: unknown;
7879
isApproved?: unknown;
@@ -89,12 +90,14 @@ export const onRequestPatch: PagesFunction<Env> = async ({ request, env, params
8990
body.username !== undefined ||
9091
body.email !== undefined ||
9192
body.bio !== undefined ||
93+
body.accessRequestNote !== undefined ||
9294
body.avatarUrl !== undefined
9395
) {
9496
user = await updateUserProfile(env, targetId, {
9597
username: body.username,
9698
email: body.email,
9799
bio: body.bio,
100+
accessRequestNote: body.accessRequestNote,
98101
avatarUrl: body.avatarUrl,
99102
});
100103
}

src/components/AppShell.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,50 @@
11
import { useEffect, useState } from "react";
2+
import { fetchMe } from "../lib/cloudUser";
23
import { useAppStore } from "../store/appStore";
34
import { LinkProfileChart } from "./LinkProfileChart";
45
import { MapView } from "./MapView";
56
import { Sidebar } from "./Sidebar";
7+
import { UserAdminPanel } from "./UserAdminPanel";
68

79
export function AppShell() {
810
const srtmTilesCount = useAppStore((state) => state.srtmTiles.length);
911
const recommendAndFetchTerrainForCurrentArea = useAppStore(
1012
(state) => state.recommendAndFetchTerrainForCurrentArea,
1113
);
1214
const [isMapExpanded, setIsMapExpanded] = useState(false);
15+
const [accessState, setAccessState] = useState<"checking" | "granted" | "pending">("checking");
1316

1417
useEffect(() => {
1518
if (srtmTilesCount > 0) return;
1619
void recommendAndFetchTerrainForCurrentArea();
1720
}, [recommendAndFetchTerrainForCurrentArea, srtmTilesCount]);
1821

22+
useEffect(() => {
23+
void (async () => {
24+
try {
25+
const me = await fetchMe();
26+
setAccessState(me.isAdmin || me.isApproved ? "granted" : "pending");
27+
} catch {
28+
setAccessState("granted");
29+
}
30+
})();
31+
}, []);
32+
33+
if (accessState === "pending") {
34+
return (
35+
<main className="app-shell access-locked-shell">
36+
<section className="panel-section access-locked-panel">
37+
<UserAdminPanel />
38+
<h2>Account Pending Approval</h2>
39+
<p className="field-help">
40+
Complete your profile and add an access request note. An admin must approve your account before you can use
41+
simulations or libraries.
42+
</p>
43+
</section>
44+
</main>
45+
);
46+
}
47+
1948
return (
2049
<main className={`app-shell ${isMapExpanded ? "is-map-expanded" : ""}`}>
2150
{!isMapExpanded ? <Sidebar /> : null}

src/components/UserAdminPanel.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export function UserAdminPanel() {
5454
const [nameDraft, setNameDraft] = useState("");
5555
const [emailDraft, setEmailDraft] = useState("");
5656
const [bioDraft, setBioDraft] = useState("");
57+
const [accessRequestNoteDraft, setAccessRequestNoteDraft] = useState("");
5758
const [avatarDraft, setAvatarDraft] = useState("");
5859

5960
const canAdmin = Boolean(me?.isAdmin);
@@ -67,6 +68,7 @@ export function UserAdminPanel() {
6768
setNameDraft(current.username);
6869
setEmailDraft(current.email ?? "");
6970
setBioDraft(current.bio ?? "");
71+
setAccessRequestNoteDraft(current.accessRequestNote ?? "");
7072
setAvatarDraft(current.avatarUrl ?? "");
7173
if (current.isAdmin) {
7274
const all = await fetchUsers();
@@ -96,6 +98,7 @@ export function UserAdminPanel() {
9698
username: nameDraft,
9799
email: emailDraft,
98100
bio: bioDraft,
101+
accessRequestNote: accessRequestNoteDraft,
99102
avatarUrl: avatarDraft,
100103
});
101104
setMe(updated);
@@ -225,6 +228,15 @@ export function UserAdminPanel() {
225228
<span>Bio</span>
226229
<textarea maxLength={300} onChange={(event) => setBioDraft(event.target.value)} value={bioDraft} />
227230
</label>
231+
<label className="field-grid user-bio-field user-field-grid">
232+
<span>Access request note</span>
233+
<textarea
234+
maxLength={1200}
235+
onChange={(event) => setAccessRequestNoteDraft(event.target.value)}
236+
placeholder="Optional private note to admins."
237+
value={accessRequestNoteDraft}
238+
/>
239+
</label>
228240
<div className="chip-group">
229241
<button
230242
className="inline-action"
@@ -289,6 +301,7 @@ function ManagedUserRow({
289301
<div className="field-help">
290302
{user.id} | created {fmtDate(user.createdAt)} | access {user.isApproved ? "approved" : "pending"}
291303
</div>
304+
{user.accessRequestNote ? <p className="field-help">Request: {user.accessRequestNote}</p> : null}
292305
<label className="field-grid user-field-grid">
293306
<span>Name</span>
294307
<input onChange={(event) => setNameDraft(event.target.value)} type="text" value={nameDraft} />

src/index.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ input {
109109
grid-template-columns: minmax(0, 1fr);
110110
}
111111

112+
.access-locked-shell {
113+
grid-template-columns: minmax(320px, 560px);
114+
justify-content: center;
115+
align-content: start;
116+
}
117+
118+
.access-locked-panel {
119+
margin-top: 28px;
120+
}
121+
112122
@keyframes fade-in {
113123
from {
114124
opacity: 0;

src/lib/cloudUser.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export type CloudUser = {
33
username: string;
44
email?: string;
55
bio: string;
6+
accessRequestNote?: string;
67
avatarUrl: string;
78
isAdmin: boolean;
89
isApproved: boolean;
@@ -51,6 +52,7 @@ export const updateMyProfile = async (patch: {
5152
username?: string;
5253
email?: string;
5354
bio?: string;
55+
accessRequestNote?: string;
5456
avatarUrl?: string;
5557
}): Promise<CloudUser> => {
5658
const data = await apiCall<{ user: CloudUser }>("/api/me", {
@@ -83,7 +85,13 @@ export const updateUserApproval = async (id: string, isApproved: boolean): Promi
8385

8486
export const updateUserProfile = async (
8587
id: string,
86-
patch: { username?: string; email?: string; bio?: string; avatarUrl?: string },
88+
patch: {
89+
username?: string;
90+
email?: string;
91+
bio?: string;
92+
accessRequestNote?: string;
93+
avatarUrl?: string;
94+
},
8795
): Promise<CloudUser> => {
8896
const data = await apiCall<{ user: CloudUser }>(`/api/users/${encodeURIComponent(id)}`, {
8997
method: "PATCH",

0 commit comments

Comments
 (0)