Merge pull request 'portal: collect names for access requests' (#8) from feature/ariadne-integration-portal into master
Reviewed-on: #8
This commit is contained in:
commit
8e580381d0
@ -30,6 +30,8 @@ def ensure_schema() -> None:
|
|||||||
CREATE TABLE IF NOT EXISTS access_requests (
|
CREATE TABLE IF NOT EXISTS access_requests (
|
||||||
request_code TEXT PRIMARY KEY,
|
request_code TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
contact_email TEXT,
|
contact_email TEXT,
|
||||||
note TEXT,
|
note TEXT,
|
||||||
status TEXT NOT NULL,
|
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_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 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 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_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 approval_note TEXT")
|
||||||
conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS denial_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
|
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 {}
|
payload = request.get_json(silent=True) or {}
|
||||||
username = (payload.get("username") or "").strip()
|
username = (payload.get("username") or "").strip()
|
||||||
email = (payload.get("email") or "").strip()
|
email = (payload.get("email") or "").strip()
|
||||||
note = (payload.get("note") 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:
|
def _validate_username(username: str) -> str | None:
|
||||||
@ -362,7 +379,9 @@ def register(app) -> None:
|
|||||||
return jsonify({"error": "server not configured"}), 503
|
return jsonify({"error": "server not configured"}), 503
|
||||||
|
|
||||||
ip = _client_ip()
|
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
|
rate_key = ip
|
||||||
if username:
|
if username:
|
||||||
@ -378,6 +397,12 @@ def register(app) -> None:
|
|||||||
username_error = _validate_username(username)
|
username_error = _validate_username(username)
|
||||||
if username_error:
|
if username_error:
|
||||||
return jsonify({"error": username_error}), 400
|
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:
|
if not email:
|
||||||
return jsonify({"error": "email is required"}), 400
|
return jsonify({"error": "email is required"}), 400
|
||||||
if "@" not in email:
|
if "@" not in email:
|
||||||
@ -418,12 +443,22 @@ def register(app) -> None:
|
|||||||
UPDATE access_requests
|
UPDATE access_requests
|
||||||
SET contact_email = %s,
|
SET contact_email = %s,
|
||||||
note = %s,
|
note = %s,
|
||||||
|
first_name = %s,
|
||||||
|
last_name = %s,
|
||||||
email_verification_token_hash = %s,
|
email_verification_token_hash = %s,
|
||||||
email_verification_sent_at = NOW(),
|
email_verification_sent_at = NOW(),
|
||||||
email_verified_at = NULL
|
email_verified_at = NULL
|
||||||
WHERE request_code = %s AND status = %s
|
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)
|
verify_url = _verify_url(request_code, token)
|
||||||
@ -447,12 +482,21 @@ def register(app) -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO access_requests
|
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)
|
email_verification_token_hash, email_verification_sent_at)
|
||||||
VALUES
|
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:
|
except psycopg.errors.UniqueViolation:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
|
|||||||
@ -27,7 +27,7 @@ def register(app) -> None:
|
|||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
rows = conn.execute(
|
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
|
FROM access_requests
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
@ -44,6 +44,8 @@ def register(app) -> None:
|
|||||||
"id": row["request_code"],
|
"id": row["request_code"],
|
||||||
"username": row["username"],
|
"username": row["username"],
|
||||||
"email": row.get("contact_email") or "",
|
"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"],
|
"request_code": row["request_code"],
|
||||||
"created_at": (row.get("created_at").isoformat() if row.get("created_at") else ""),
|
"created_at": (row.get("created_at").isoformat() if row.get("created_at") else ""),
|
||||||
"note": row.get("note") or "",
|
"note": row.get("note") or "",
|
||||||
|
|||||||
@ -354,6 +354,8 @@
|
|||||||
<div v-else class="requests">
|
<div v-else class="requests">
|
||||||
<div v-for="req in admin.requests" :key="req.username" class="req-row">
|
<div v-for="req in admin.requests" :key="req.username" class="req-row">
|
||||||
<div class="req-summary">
|
<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="req-label mono">User</div>
|
||||||
<div class="mono">{{ req.username }}</div>
|
<div class="mono">{{ req.username }}</div>
|
||||||
<div class="req-label mono">Email</div>
|
<div class="req-label mono">Email</div>
|
||||||
@ -658,6 +660,18 @@ function hasFlag(username, flag) {
|
|||||||
return Array.isArray(selected) && selected.includes(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) {
|
function toggleFlag(username, flag, event) {
|
||||||
const checked = Boolean(event?.target?.checked);
|
const checked = Boolean(event?.target?.checked);
|
||||||
const selected = Array.isArray(admin.selectedFlags[username]) ? [...admin.selectedFlags[username]] : [];
|
const selected = Array.isArray(admin.selectedFlags[username]) ? [...admin.selectedFlags[username]] : [];
|
||||||
|
|||||||
@ -41,6 +41,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</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">
|
<label class="field">
|
||||||
<span class="label mono">Email</span>
|
<span class="label mono">Email</span>
|
||||||
<input
|
<input
|
||||||
@ -67,7 +93,11 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="actions">
|
<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" }}
|
{{ submitting ? "Submitting..." : "Submit request" }}
|
||||||
</button>
|
</button>
|
||||||
<span class="hint mono">Requests are rate-limited.</span>
|
<span class="hint mono">Requests are rate-limited.</span>
|
||||||
@ -187,6 +217,8 @@ function statusPillClass(value) {
|
|||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: "",
|
username: "",
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
email: "",
|
email: "",
|
||||||
note: "",
|
note: "",
|
||||||
});
|
});
|
||||||
@ -322,6 +354,8 @@ async function submit() {
|
|||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
|
first_name: form.first_name.trim(),
|
||||||
|
last_name: form.last_name.trim(),
|
||||||
email: form.email.trim(),
|
email: form.email.trim(),
|
||||||
note: form.note.trim(),
|
note: form.note.trim(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user