portal: collect names for access requests #8

Merged
bstein merged 1 commits from feature/ariadne-integration-portal into master 2026-01-21 22:51:43 +00:00
5 changed files with 107 additions and 9 deletions

View File

@ -30,6 +30,8 @@ def ensure_schema() -> None:
CREATE TABLE IF NOT EXISTS access_requests (
request_code TEXT PRIMARY KEY,
username TEXT NOT NULL,
first_name TEXT,
last_name TEXT,
contact_email TEXT,
note TEXT,
status TEXT NOT NULL,
@ -52,6 +54,8 @@ def ensure_schema() -> None:
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS email_verification_sent_at TIMESTAMPTZ")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS welcome_email_sent_at TIMESTAMPTZ")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS first_name TEXT")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS last_name TEXT")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS approval_flags TEXT[]")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS approval_note TEXT")
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS denial_note TEXT")

View File

@ -22,12 +22,29 @@ from ..provisioning import provision_access_request, provision_tasks_complete
from .. import settings
def _extract_request_payload() -> tuple[str, str, str]:
def _extract_request_payload() -> tuple[str, str, str, str, str]:
payload = request.get_json(silent=True) or {}
username = (payload.get("username") or "").strip()
email = (payload.get("email") or "").strip()
note = (payload.get("note") or "").strip()
return username, email, note
first_name = (payload.get("first_name") or "").strip()
last_name = (payload.get("last_name") or "").strip()
return username, email, note, first_name, last_name
def _normalize_name(value: str) -> str:
return " ".join(value.strip().split())
def _validate_name(value: str, *, label: str, required: bool) -> str | None:
cleaned = _normalize_name(value)
if not cleaned:
return f"{label} is required" if required else None
if len(cleaned) > 80:
return f"{label} must be 1-80 characters"
if any(ch in "\r\n\t" for ch in cleaned):
return f"{label} contains invalid whitespace"
return None
def _validate_username(username: str) -> str | None:
@ -362,7 +379,9 @@ def register(app) -> None:
return jsonify({"error": "server not configured"}), 503
ip = _client_ip()
username, email, note = _extract_request_payload()
username, email, note, first_name, last_name = _extract_request_payload()
first_name = _normalize_name(first_name)
last_name = _normalize_name(last_name)
rate_key = ip
if username:
@ -378,6 +397,12 @@ def register(app) -> None:
username_error = _validate_username(username)
if username_error:
return jsonify({"error": username_error}), 400
name_error = _validate_name(first_name, label="first name", required=False)
if name_error:
return jsonify({"error": name_error}), 400
name_error = _validate_name(last_name, label="last name", required=True)
if name_error:
return jsonify({"error": name_error}), 400
if not email:
return jsonify({"error": "email is required"}), 400
if "@" not in email:
@ -418,12 +443,22 @@ def register(app) -> None:
UPDATE access_requests
SET contact_email = %s,
note = %s,
first_name = %s,
last_name = %s,
email_verification_token_hash = %s,
email_verification_sent_at = NOW(),
email_verified_at = NULL
WHERE request_code = %s AND status = %s
""",
(email, note or None, token_hash, request_code, EMAIL_VERIFY_PENDING_STATUS),
(
email,
note or None,
first_name or None,
last_name or None,
token_hash,
request_code,
EMAIL_VERIFY_PENDING_STATUS,
),
)
verify_url = _verify_url(request_code, token)
@ -447,12 +482,21 @@ def register(app) -> None:
conn.execute(
"""
INSERT INTO access_requests
(request_code, username, contact_email, note, status,
(request_code, username, contact_email, note, first_name, last_name, status,
email_verification_token_hash, email_verification_sent_at)
VALUES
(%s, %s, %s, %s, %s, %s, NOW())
(%s, %s, %s, %s, %s, %s, %s, %s, NOW())
""",
(request_code, username, email, note or None, EMAIL_VERIFY_PENDING_STATUS, token_hash),
(
request_code,
username,
email,
note or None,
first_name or None,
last_name or None,
EMAIL_VERIFY_PENDING_STATUS,
token_hash,
),
)
except psycopg.errors.UniqueViolation:
conn.rollback()

View File

@ -27,7 +27,7 @@ def register(app) -> None:
with connect() as conn:
rows = conn.execute(
"""
SELECT request_code, username, contact_email, note, status, created_at
SELECT request_code, username, contact_email, first_name, last_name, note, status, created_at
FROM access_requests
WHERE status = 'pending'
ORDER BY created_at ASC
@ -44,6 +44,8 @@ def register(app) -> None:
"id": row["request_code"],
"username": row["username"],
"email": row.get("contact_email") or "",
"first_name": row.get("first_name") or "",
"last_name": row.get("last_name") or "",
"request_code": row["request_code"],
"created_at": (row.get("created_at").isoformat() if row.get("created_at") else ""),
"note": row.get("note") or "",

View File

@ -354,6 +354,8 @@
<div v-else class="requests">
<div v-for="req in admin.requests" :key="req.username" class="req-row">
<div class="req-summary">
<div class="req-label mono">Name</div>
<div class="mono">{{ formatName(req) }}</div>
<div class="req-label mono">User</div>
<div class="mono">{{ req.username }}</div>
<div class="req-label mono">Email</div>
@ -658,6 +660,18 @@ function hasFlag(username, flag) {
return Array.isArray(selected) && selected.includes(flag);
}
function formatName(req) {
if (!req) return "unknown";
const parts = [];
if (req.first_name && String(req.first_name).trim()) {
parts.push(String(req.first_name).trim());
}
if (req.last_name && String(req.last_name).trim()) {
parts.push(String(req.last_name).trim());
}
return parts.length ? parts.join(" ") : "unknown";
}
function toggleFlag(username, flag, event) {
const checked = Boolean(event?.target?.checked);
const selected = Array.isArray(admin.selectedFlags[username]) ? [...admin.selectedFlags[username]] : [];

View File

@ -41,6 +41,32 @@
</div>
</label>
<label class="field">
<span class="label mono">Last name</span>
<input
v-model="form.last_name"
class="input"
type="text"
autocomplete="family-name"
placeholder="e.g. Stein"
:disabled="submitting"
required
/>
<span class="hint mono">Required for account provisioning.</span>
</label>
<label class="field">
<span class="label mono">First name (optional)</span>
<input
v-model="form.first_name"
class="input"
type="text"
autocomplete="given-name"
placeholder="e.g. Brad"
:disabled="submitting"
/>
</label>
<label class="field">
<span class="label mono">Email</span>
<input
@ -67,7 +93,11 @@
</label>
<div class="actions">
<button class="primary" type="submit" :disabled="submitting || !form.username.trim() || availability.blockSubmit">
<button
class="primary"
type="submit"
:disabled="submitting || !form.username.trim() || !form.last_name.trim() || availability.blockSubmit"
>
{{ submitting ? "Submitting..." : "Submit request" }}
</button>
<span class="hint mono">Requests are rate-limited.</span>
@ -187,6 +217,8 @@ function statusPillClass(value) {
const form = reactive({
username: "",
first_name: "",
last_name: "",
email: "",
note: "",
});
@ -322,6 +354,8 @@ async function submit() {
cache: "no-store",
body: JSON.stringify({
username: form.username.trim(),
first_name: form.first_name.trim(),
last_name: form.last_name.trim(),
email: form.email.trim(),
note: form.note.trim(),
}),