portal: collect names for access requests #8
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 "",
|
||||
|
||||
@ -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]] : [];
|
||||
|
||||
@ -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(),
|
||||
}),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user