feat(web): manual create flow with input dialog for acc and user

api-server gets /create-acc-data and /create-user-data POST routes
that INSERT into the respective tables with required-field validation.
Frontend adds an 'Add' button next to Refresh in each table head;
opens a native <dialog> form with all fields. Inputs use 16px font on
phone (sm:text-[13px] desktop) so iOS doesn't auto-zoom.

A small form-dialog-shell helper centralizes the modal chrome,
field label, and input class so create-account-dialog and
create-user-dialog stay focused on their fields and validation.
This commit is contained in:
yiekheng 2026-05-02 21:19:24 +08:00
parent e507714dc5
commit e3ac94cada
7 changed files with 512 additions and 28 deletions

View File

@ -60,6 +60,10 @@ class CM_API:
self.app.route('/delete-acc-data', methods=['POST'])(self.delete_acc_data) self.app.route('/delete-acc-data', methods=['POST'])(self.delete_acc_data)
self.app.route('/delete-user-data', methods=['POST'])(self.delete_user_data) self.app.route('/delete-user-data', methods=['POST'])(self.delete_user_data)
# Create routes (manual operator input)
self.app.route('/create-acc-data', methods=['POST'])(self.create_acc_data)
self.app.route('/create-user-data', methods=['POST'])(self.create_user_data)
def _check_database_available(self): def _check_database_available(self):
db = self._get_database_connection() db = self._get_database_connection()
if db is None: if db is None:
@ -224,6 +228,64 @@ class CM_API:
finally: finally:
self._close_database_connection(db) self._close_database_connection(db)
def create_acc_data(self):
is_available, db, error_response = self._check_database_available()
if not is_available:
return error_response
try:
data = request.get_json() or {}
username = (data.get('username') or '').strip()
password = data.get('password') or ''
status = data.get('status') or ''
link = data.get('link') or ''
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
result = db.execute(
"INSERT INTO acc (username, password, status, link) VALUES (%s, %s, %s, %s)",
[username, password, status, link]
)
if result:
return jsonify({"created": username})
return jsonify({"error": "Failed to create account"}), 500
except Exception as error:
return self._handle_error(error, "Error creating account"), 500
finally:
self._close_database_connection(db)
def create_user_data(self):
is_available, db, error_response = self._check_database_available()
if not is_available:
return error_response
try:
data = request.get_json() or {}
f_username = (data.get('f_username') or '').strip()
f_password = data.get('f_password') or ''
t_username = (data.get('t_username') or '').strip()
t_password = data.get('t_password') or ''
if not f_username or not f_password or not t_username or not t_password:
return jsonify({"error": "All fields are required"}), 400
result = db.execute(
"INSERT INTO user (f_username, f_password, t_username, t_password) VALUES (%s, %s, %s, %s)",
[f_username, f_password, t_username, t_password]
)
if result:
return jsonify({"created": f_username})
return jsonify({"error": "Failed to create user"}), 500
except Exception as error:
return self._handle_error(error, "Error creating user"), 500
finally:
self._close_database_connection(db)
def run(self, port=3000, debug=None): def run(self, port=3000, debug=None):
if debug is None: if debug is None:
debug = _debug_enabled() debug = _debug_enabled()

View File

@ -26,6 +26,26 @@ export async function updateUser(data: UserUpdate): Promise<ActionResult> {
} }
} }
export async function createAccount(data: AccUpdate): Promise<ActionResult> {
try {
await fetchApi("/create-acc-data", { method: "POST", body: data });
revalidatePath("/");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function createUser(data: UserUpdate): Promise<ActionResult> {
try {
await fetchApi("/create-user-data", { method: "POST", body: data });
revalidatePath("/users");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function deleteAccount(username: string): Promise<ActionResult> { export async function deleteAccount(username: string): Promise<ActionResult> {
try { try {
await fetchApi("/delete-acc-data", { method: "POST", body: { username } }); await fetchApi("/delete-acc-data", { method: "POST", body: { username } });

View File

@ -6,6 +6,7 @@ import type { Acc } from "@/lib/types";
import { deleteAccount, updateAccount } from "@/app/actions"; import { deleteAccount, updateAccount } from "@/app/actions";
import EditableCell from "./editable-cell"; import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog"; import ConfirmDialog from "./confirm-dialog";
import CreateAccountDialog from "./create-account-dialog";
type Props = { initial: Acc[]; prefixPattern: string }; type Props = { initial: Acc[]; prefixPattern: string };
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
@ -69,6 +70,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
const [deleteTarget, setDeleteTarget] = useState<string | null>(null); const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>( const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
initial, initial,
@ -120,19 +122,34 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
if (initial.length === 0) { if (initial.length === 0) {
return ( return (
<div> <div>
<PageHead count={0} onRefresh={refresh} refreshing={refreshing} /> <PageHead
count={0}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
/>
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60"> <div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
<p className="text-sm text-zinc-500"> <p className="text-sm text-zinc-500">
No accounts yet. The monitor will create some on the next run. No accounts yet. Click <span className="font-medium text-zinc-700">Add</span> above to create one manually, or wait for the monitor.
</p> </p>
</div> </div>
<CreateAccountDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
prefixPattern={prefixPattern}
/>
</div> </div>
); );
} }
return ( return (
<div> <div>
<PageHead count={optimistic.length} onRefresh={refresh} refreshing={refreshing} /> <PageHead
count={optimistic.length}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
/>
{/* Desktop / tablet table */} {/* Desktop / tablet table */}
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block"> <div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block">
@ -270,6 +287,12 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
})} })}
</div> </div>
<CreateAccountDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
prefixPattern={prefixPattern}
/>
<ConfirmDialog <ConfirmDialog
open={!!deleteTarget} open={!!deleteTarget}
onCancel={() => { onCancel={() => {
@ -326,10 +349,12 @@ function PageHead({
count, count,
onRefresh, onRefresh,
refreshing, refreshing,
onAdd,
}: { }: {
count: number; count: number;
onRefresh: () => void; onRefresh: () => void;
refreshing: boolean; refreshing: boolean;
onAdd: () => void;
}) { }) {
return ( return (
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex flex-wrap items-end justify-between gap-4">
@ -344,17 +369,27 @@ function PageHead({
</span> </span>
</h1> </h1>
</div> </div>
<button <div className="flex items-center gap-2">
type="button" <button
onClick={onRefresh} type="button"
disabled={refreshing} onClick={onRefresh}
className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-medium text-zinc-700 shadow-sm ring-1 ring-zinc-200 transition-colors hover:bg-zinc-50 hover:text-zinc-900 disabled:opacity-60" disabled={refreshing}
> className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-medium text-zinc-700 shadow-sm ring-1 ring-zinc-200 transition-colors hover:bg-zinc-50 hover:text-zinc-900 disabled:opacity-60"
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}> >
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
</span>
Refresh </span>
</button> Refresh
</button>
<button
type="button"
onClick={onAdd}
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
>
<span aria-hidden="true">+</span>
Add
</button>
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,109 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { createAccount } from "@/app/actions";
import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
type Props = {
open: boolean;
onClose: () => void;
prefixPattern?: string;
};
export default function CreateAccountDialog({ open, onClose, prefixPattern }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [status, setStatus] = useState("");
const [link, setLink] = useState("");
// Reset on open.
useEffect(() => {
if (open) {
setUsername("");
setPassword("");
setStatus("");
setLink("");
setError(null);
}
}, [open]);
function submit() {
if (!username.trim() || !password) {
setError("Username and password are required");
return;
}
setError(null);
startTransition(async () => {
const result = await createAccount({
username: username.trim(),
password,
status,
link,
});
if (result.ok) {
onClose();
} else {
setError(result.error);
}
});
}
return (
<FormDialogShell
open={open}
onCancel={onClose}
onSubmit={submit}
title="New account"
submitLabel="Create"
pending={pending}
error={error}
>
<Field label="Username" required hint={prefixPattern ? `Suggested prefix: ${prefixPattern}` : undefined}>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={pending}
autoFocus
autoComplete="off"
spellCheck={false}
placeholder={prefixPattern ? `${prefixPattern}1234` : "username"}
className={inputClass}
/>
</Field>
<Field label="Password" required>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
className={inputClass}
/>
</Field>
<Field label="Status" hint="Empty | wait | done">
<input
value={status}
onChange={(e) => setStatus(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
placeholder="(leave blank for available)"
className={inputClass}
/>
</Field>
<Field label="Link">
<input
value={link}
onChange={(e) => setLink(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
placeholder="https://..."
className={inputClass}
/>
</Field>
</FormDialogShell>
);
}

View File

@ -0,0 +1,104 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { createUser } from "@/app/actions";
import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
type Props = {
open: boolean;
onClose: () => void;
};
export default function CreateUserDialog({ open, onClose }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [fUsername, setFUsername] = useState("");
const [fPassword, setFPassword] = useState("");
const [tUsername, setTUsername] = useState("");
const [tPassword, setTPassword] = useState("");
useEffect(() => {
if (open) {
setFUsername("");
setFPassword("");
setTUsername("");
setTPassword("");
setError(null);
}
}, [open]);
function submit() {
if (!fUsername.trim() || !fPassword || !tUsername.trim() || !tPassword) {
setError("All fields are required");
return;
}
setError(null);
startTransition(async () => {
const result = await createUser({
f_username: fUsername.trim(),
f_password: fPassword,
t_username: tUsername.trim(),
t_password: tPassword,
});
if (result.ok) {
onClose();
} else {
setError(result.error);
}
});
}
return (
<FormDialogShell
open={open}
onCancel={onClose}
onSubmit={submit}
title="New user pairing"
submitLabel="Create"
pending={pending}
error={error}
>
<Field label="From username" required>
<input
value={fUsername}
onChange={(e) => setFUsername(e.target.value)}
disabled={pending}
autoFocus
autoComplete="off"
spellCheck={false}
className={inputClass}
/>
</Field>
<Field label="From password" required>
<input
value={fPassword}
onChange={(e) => setFPassword(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
className={inputClass}
/>
</Field>
<Field label="To username" required>
<input
value={tUsername}
onChange={(e) => setTUsername(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
className={inputClass}
/>
</Field>
<Field label="To password (security PIN)" required>
<input
value={tPassword}
onChange={(e) => setTPassword(e.target.value)}
disabled={pending}
autoComplete="off"
spellCheck={false}
className={inputClass}
/>
</Field>
</FormDialogShell>
);
}

View File

@ -0,0 +1,125 @@
"use client";
import { useEffect, useRef } from "react";
/**
* Shared <dialog>-based modal shell. Owners pass form contents and a
* submit handler; this component handles open/close imperatively, Esc
* cancellation, and backdrop click cancellation. Native <dialog> gives
* us focus trapping and ::backdrop styling for free.
*/
export default function FormDialogShell({
open,
onCancel,
onSubmit,
title,
children,
cancelLabel = "Cancel",
submitLabel = "Save",
destructive = false,
pending = false,
error,
}: {
open: boolean;
onCancel: () => void;
onSubmit: () => void;
title: string;
children: React.ReactNode;
cancelLabel?: string;
submitLabel?: string;
destructive?: boolean;
pending?: boolean;
error?: string | null;
}) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
else if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={ref}
onClose={() => {
if (!pending) onCancel();
}}
onClick={(e) => {
if (pending) return;
if (e.target === ref.current) onCancel();
}}
className="m-auto w-[min(92vw,480px)] rounded-2xl bg-white p-0 ring-1 ring-zinc-200/60 backdrop:bg-zinc-900/50 backdrop:backdrop-blur-sm"
>
<form
method="dialog"
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="flex flex-col gap-4 p-6"
>
<h2 className="text-lg font-semibold tracking-tight text-zinc-900">{title}</h2>
<div className="flex flex-col gap-3">{children}</div>
{error && (
<p className="font-mono text-[11px] text-red-600" role="alert">
{error}
</p>
)}
<div className="mt-2 flex items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={pending}
className="rounded-full px-4 py-2 text-xs font-medium text-zinc-700 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-60"
>
{cancelLabel}
</button>
<button
type="submit"
disabled={pending}
className={`rounded-full px-4 py-2 text-xs font-medium text-white transition-colors disabled:opacity-60 ${
destructive
? "bg-red-600 hover:bg-red-700"
: "bg-zinc-900 hover:bg-zinc-700"
}`}
>
{pending ? "…" : submitLabel}
</button>
</div>
</form>
</dialog>
);
}
/**
* A labelled text input that uses 16px font-size on phones (preventing
* iOS Safari auto-zoom-on-focus) and falls back to a tighter 13px on
* tablets/desktop where there's no auto-zoom behavior.
*/
export function Field({
label,
required,
hint,
children,
}: {
label: string;
required?: boolean;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-1">
<span className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</span>
{children}
{hint && <span className="text-[11px] text-zinc-500">{hint}</span>}
</label>
);
}
export const inputClass =
"w-full min-w-0 rounded-md border-0 bg-zinc-100 px-3 py-2 font-mono text-base text-zinc-900 outline-none ring-1 ring-zinc-300 transition-colors focus:bg-white focus:ring-2 focus:ring-zinc-900 disabled:opacity-60 sm:text-[13px]";

View File

@ -6,6 +6,7 @@ import type { User } from "@/lib/types";
import { deleteUser, updateUser } from "@/app/actions"; import { deleteUser, updateUser } from "@/app/actions";
import EditableCell from "./editable-cell"; import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog"; import ConfirmDialog from "./confirm-dialog";
import CreateUserDialog from "./create-user-dialog";
type Props = { initial: User[]; prefixPattern: string }; type Props = { initial: User[]; prefixPattern: string };
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
@ -82,6 +83,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
const [deleteTarget, setDeleteTarget] = useState<string | null>(null); const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>( const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
initial, initial,
@ -167,17 +169,30 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
if (initial.length === 0) { if (initial.length === 0) {
return ( return (
<div> <div>
<PageHead count={0} onRefresh={refresh} refreshing={refreshing} /> <PageHead
count={0}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
/>
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60"> <div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
<p className="text-sm text-zinc-500">No users yet.</p> <p className="text-sm text-zinc-500">
No users yet. Click <span className="font-medium text-zinc-700">Add</span> to create one manually.
</p>
</div> </div>
<CreateUserDialog open={createOpen} onClose={() => setCreateOpen(false)} />
</div> </div>
); );
} }
return ( return (
<div> <div>
<PageHead count={optimistic.length} onRefresh={refresh} refreshing={refreshing} /> <PageHead
count={optimistic.length}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
/>
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block"> <div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block">
<table className="w-full table-fixed border-collapse"> <table className="w-full table-fixed border-collapse">
@ -317,6 +332,8 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
})} })}
</div> </div>
<CreateUserDialog open={createOpen} onClose={() => setCreateOpen(false)} />
<ConfirmDialog <ConfirmDialog
open={!!deleteTarget} open={!!deleteTarget}
onCancel={() => { onCancel={() => {
@ -362,10 +379,12 @@ function PageHead({
count, count,
onRefresh, onRefresh,
refreshing, refreshing,
onAdd,
}: { }: {
count: number; count: number;
onRefresh: () => void; onRefresh: () => void;
refreshing: boolean; refreshing: boolean;
onAdd: () => void;
}) { }) {
return ( return (
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex flex-wrap items-end justify-between gap-4">
@ -376,17 +395,27 @@ function PageHead({
<span className="ml-2 align-middle text-base font-medium text-zinc-400">{count}</span> <span className="ml-2 align-middle text-base font-medium text-zinc-400">{count}</span>
</h1> </h1>
</div> </div>
<button <div className="flex items-center gap-2">
type="button" <button
onClick={onRefresh} type="button"
disabled={refreshing} onClick={onRefresh}
className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-medium text-zinc-700 shadow-sm ring-1 ring-zinc-200 transition-colors hover:bg-zinc-50 hover:text-zinc-900 disabled:opacity-60" disabled={refreshing}
> className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-medium text-zinc-700 shadow-sm ring-1 ring-zinc-200 transition-colors hover:bg-zinc-50 hover:text-zinc-900 disabled:opacity-60"
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}> >
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
</span>
Refresh </span>
</button> Refresh
</button>
<button
type="button"
onClick={onAdd}
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
>
<span aria-hidden="true">+</span>
Add
</button>
</div>
</div> </div>
); );
} }