Add keypass schedule

This commit is contained in:
Ettore
2026-05-22 20:49:03 +02:00
parent 4389a20e90
commit eeea8dfad8
6 changed files with 185 additions and 4 deletions

View File

@@ -59,6 +59,7 @@ class Keypass(Base):
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
revoked_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
allowed_gates: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON list of gate IDs; NULL = all gates
schedule: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON schedule rule; NULL = always allowed
class GateAccessLog(Base):
@@ -107,3 +108,9 @@ def get_db():
def init_db() -> None:
os.makedirs(DATA_DIR, exist_ok=True)
Base.metadata.create_all(bind=engine)
# Lightweight migrations: add columns that may not exist in older databases
with engine.connect() as conn:
existing = {row[1] for row in conn.execute(text("PRAGMA table_info(keypasses)"))}
if "schedule" not in existing:
conn.execute(text("ALTER TABLE keypasses ADD COLUMN schedule TEXT"))
conn.commit()

View File

@@ -1,3 +1,4 @@
import json
from datetime import datetime
from typing import Optional
@@ -48,4 +49,19 @@ def require_keypass(
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked")
if kp.expires_at is not None and kp.expires_at < utcnow():
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired")
if kp.schedule:
try:
sched = json.loads(kp.schedule)
except (ValueError, TypeError):
sched = {}
now_local = datetime.now()
allowed_days = sched.get("days")
if allowed_days is not None and now_local.weekday() not in allowed_days:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keypass not currently allowed")
time_start = sched.get("time_start")
time_end = sched.get("time_end")
if time_start and time_end:
now_hhmm = now_local.strftime("%H:%M")
if now_hhmm < time_start or now_hhmm > time_end:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keypass not currently allowed")
return kp

View File

@@ -32,6 +32,13 @@ class AdminLoginResponse(BaseModel):
# ── Keypasses ─────────────────────────────────────────────────────────────────
class ScheduleRule(BaseModel):
"""Optional time/day-of-week restriction for a keypass."""
days: Optional[list[int]] = None # 0=Mon..6=Sun; None/absent = any day
time_start: Optional[str] = None # "HH:MM" 24-hour server local time
time_end: Optional[str] = None # "HH:MM" 24-hour server local time
class KeypassCreate(BaseModel):
description: str
expires_at: Optional[datetime] = None # None = never expires
@@ -40,12 +47,14 @@ class KeypassCreate(BaseModel):
# Auto-generation options (ignored when `code` is supplied manually)
length: int = 12 # 632
charset: str = "alphanumeric" # "alphanumeric" | "alpha" | "numeric" | "passphrase"
schedule: Optional[ScheduleRule] = None # None = always allowed
class KeypassPatch(BaseModel):
description: Optional[str] = None
expires_at: Optional[datetime] = None # None = never expires
gate_ids: Optional[list[int]] = None # None = keep unchanged; [] = all gates
schedule: Optional[ScheduleRule] = None # absent = keep unchanged; null = clear
class KeypassResponse(BaseModel):
@@ -58,9 +67,16 @@ class KeypassResponse(BaseModel):
revoked: bool
revoked_at: Optional[datetime] = None
allowed_gate_ids: list[int] # empty = all gates
schedule: Optional[ScheduleRule] = None
def keypass_to_response(kp: Keypass) -> KeypassResponse:
sched: Optional[ScheduleRule] = None
if kp.schedule:
try:
sched = ScheduleRule(**json.loads(kp.schedule))
except Exception:
pass
return KeypassResponse(
id=kp.id,
code=kp.code,
@@ -70,6 +86,7 @@ def keypass_to_response(kp: Keypass) -> KeypassResponse:
revoked=kp.revoked,
revoked_at=kp.revoked_at,
allowed_gate_ids=json.loads(kp.allowed_gates) if kp.allowed_gates else [],
schedule=sched,
)

View File

@@ -16,6 +16,16 @@ from core.schemas import KeypassCreate, KeypassPatch, KeypassResponse, keypass_t
router = APIRouter(prefix="/api/admin/keypasses", tags=["admin-keypasses"])
def _serialize_schedule(s) -> Optional[str]:
"""Serialize a ScheduleRule to a JSON string, or None if effectively empty."""
if s is None:
return None
d = s.model_dump(exclude_none=True)
if "days" in d and not d["days"]:
del d["days"]
return json.dumps(d) if d else None
# ── Word list for passphrase mode ─────────────────────────────────────────────
_WORDS = [
"apple", "beach", "brick", "brush", "cabin", "calm", "cedar", "chain",
@@ -102,6 +112,7 @@ async def create_keypass(
expires_at=req.expires_at,
revoked=False,
allowed_gates=json.dumps(req.gate_ids) if req.gate_ids else None,
schedule=_serialize_schedule(req.schedule),
)
db.add(kp)
db.commit()
@@ -126,6 +137,8 @@ async def update_keypass(
kp.expires_at = req.expires_at
if req.gate_ids is not None:
kp.allowed_gates = json.dumps(req.gate_ids) if req.gate_ids else None
if "schedule" in req.model_fields_set:
kp.schedule = _serialize_schedule(req.schedule)
db.commit()
db.refresh(kp)
return keypass_to_response(kp)

View File

@@ -159,6 +159,7 @@
<th>Description</th>
<th>Gates</th>
<th>Expires</th>
<th>Schedule</th>
<th>Status</th>
<th></th>
</tr>
@@ -468,6 +469,38 @@
<div id="kp-gate-checks" style="display:flex;flex-direction:column;gap:.3rem"></div>
</div>
</div>
<div class="field">
<label style="margin-bottom:.5rem">Access schedule</label>
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;color:var(--text);margin-bottom:.4rem">
<input type="checkbox" id="kp-no-schedule" checked style="width:1rem;height:1rem;flex-shrink:0" />
<span style="font-size:.9rem">Always accessible (no restriction)</span>
</label>
<div id="kp-schedule-wrap" style="display:none;flex-direction:column;gap:.75rem;padding:.75rem;background:var(--surface2);border-radius:8px;border:1px solid var(--border)">
<div>
<div style="font-size:.8rem;font-weight:600;color:var(--text-muted);margin-bottom:.4rem">Allowed days <span style="font-weight:400">(leave all unchecked for any day)</span></div>
<div style="display:flex;flex-wrap:wrap;gap:.4rem">
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="0" style="width:.9rem;height:.9rem" /> Mon</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="1" style="width:.9rem;height:.9rem" /> Tue</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="2" style="width:.9rem;height:.9rem" /> Wed</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="3" style="width:.9rem;height:.9rem" /> Thu</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="4" style="width:.9rem;height:.9rem" /> Fri</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="5" style="width:.9rem;height:.9rem" /> Sat</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-day" value="6" style="width:.9rem;height:.9rem" /> Sun</label>
</div>
</div>
<div style="display:flex;gap:.75rem;align-items:flex-end">
<div style="display:flex;flex-direction:column;gap:.3rem">
<label for="kp-time-start" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">From</label>
<input id="kp-time-start" type="time" style="width:auto;font-size:.9rem" />
</div>
<div style="display:flex;flex-direction:column;gap:.3rem">
<label for="kp-time-end" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">To</label>
<input id="kp-time-end" type="time" style="width:auto;font-size:.9rem" />
</div>
<span style="font-size:.8rem;color:var(--text-muted);padding-bottom:.35rem">(server local time)</span>
</div>
</div>
</div>
<p id="kp-error" class="error-msg hidden"></p>
<div class="modal-actions">
<button type="button" id="kp-cancel" class="btn btn-ghost">Cancel</button>
@@ -505,6 +538,38 @@
<div id="kp-edit-gate-checks" style="display:flex;flex-direction:column;gap:.3rem"></div>
</div>
</div>
<div class="field">
<label style="margin-bottom:.5rem">Access schedule</label>
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;color:var(--text);margin-bottom:.4rem">
<input type="checkbox" id="kp-edit-no-schedule" checked style="width:1rem;height:1rem;flex-shrink:0" />
<span style="font-size:.9rem">Always accessible (no restriction)</span>
</label>
<div id="kp-edit-schedule-wrap" style="display:none;flex-direction:column;gap:.75rem;padding:.75rem;background:var(--surface2);border-radius:8px;border:1px solid var(--border)">
<div>
<div style="font-size:.8rem;font-weight:600;color:var(--text-muted);margin-bottom:.4rem">Allowed days <span style="font-weight:400">(leave all unchecked for any day)</span></div>
<div style="display:flex;flex-wrap:wrap;gap:.4rem">
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="0" style="width:.9rem;height:.9rem" /> Mon</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="1" style="width:.9rem;height:.9rem" /> Tue</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="2" style="width:.9rem;height:.9rem" /> Wed</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="3" style="width:.9rem;height:.9rem" /> Thu</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="4" style="width:.9rem;height:.9rem" /> Fri</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="5" style="width:.9rem;height:.9rem" /> Sat</label>
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.88rem;padding:.25rem .5rem;background:var(--surface);border-radius:4px;border:1px solid var(--border)"><input type="checkbox" name="kp-edit-day" value="6" style="width:.9rem;height:.9rem" /> Sun</label>
</div>
</div>
<div style="display:flex;gap:.75rem;align-items:flex-end">
<div style="display:flex;flex-direction:column;gap:.3rem">
<label for="kp-edit-time-start" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">From</label>
<input id="kp-edit-time-start" type="time" style="width:auto;font-size:.9rem" />
</div>
<div style="display:flex;flex-direction:column;gap:.3rem">
<label for="kp-edit-time-end" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">To</label>
<input id="kp-edit-time-end" type="time" style="width:auto;font-size:.9rem" />
</div>
<span style="font-size:.8rem;color:var(--text-muted);padding-bottom:.35rem">(server local time)</span>
</div>
</div>
</div>
<p id="kp-edit-error" class="error-msg hidden"></p>
<div class="modal-actions">
<button type="button" id="kp-edit-cancel" class="btn btn-ghost">Cancel</button>

View File

@@ -132,12 +132,20 @@ function showToast(msg, isError = false) {
}
// ── Keypasses ─────────────────────────────────────────────────────────────────
const _DAY_SHORT = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
function formatSchedule(sched) {
if (!sched) return null;
const parts = [];
if (sched.days && sched.days.length > 0) parts.push(sched.days.map(d => _DAY_SHORT[d]).join(" "));
if (sched.time_start && sched.time_end) parts.push(`${sched.time_start}${sched.time_end}`);
return parts.length ? parts.join(" · ") : null;
}
async function loadKeypasses() {
const rows = await api("GET", "/api/admin/keypasses");
const tbody = document.getElementById("keypasses-body");
tbody.innerHTML = "";
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--text-muted);text-align:center;padding:2rem">No keypasses yet</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" style="color:var(--text-muted);text-align:center;padding:2rem">No keypasses yet</td></tr>';
return;
}
for (const kp of rows) {
@@ -156,18 +164,24 @@ async function loadKeypasses() {
? `<span style="white-space:nowrap">${fmtDate(kp.expires_at)}</span>`
: '<span style="color:var(--text-muted)">Never</span>';
const schedText = formatSchedule(kp.schedule);
const schedCell = schedText
? `<span style="font-size:.85em;white-space:nowrap">${esc(schedText)}</span>`
: '<span style="color:var(--text-muted)">Always</span>';
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code style="font-size:.95em;letter-spacing:.06em">${esc(kp.code)}</code></td>
<td>${esc(kp.description)}</td>
<td>${gatesCell}</td>
<td>${expiresCell}</td>
<td>${schedCell}</td>
<td>${badge}</td>
<td><div style="display:flex;gap:.5rem;justify-content:flex-end;white-space:nowrap">
${!kp.revoked && expMs >= now ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
data-qr-kp-id="${kp.id}" data-qr-kp-desc="${esc(kp.description)}">QR</button>` : ""}
${!kp.revoked ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
data-edit-kp='${JSON.stringify({id:kp.id, description:kp.description, expires_at:kp.expires_at, allowed_gate_ids:kp.allowed_gate_ids})}'>Edit</button>` : ""}
data-edit-kp='${JSON.stringify({id:kp.id, description:kp.description, expires_at:kp.expires_at, allowed_gate_ids:kp.allowed_gate_ids, schedule:kp.schedule})}'>Edit</button>` : ""}
${!kp.revoked && expMs >= now ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
data-kp-id="${kp.id}">Revoke</button>` : ""}
</div></td>`;
@@ -210,6 +224,17 @@ async function loadKeypasses() {
allGatesCb.checked = !allowedIds;
checksContainer.style.display = allowedIds ? "flex" : "none";
document.getElementById("kp-edit-error").classList.add("hidden");
// Schedule
const sched = kp.schedule;
const noSchedCb = document.getElementById("kp-edit-no-schedule");
const schedWrap = document.getElementById("kp-edit-schedule-wrap");
noSchedCb.checked = !sched;
schedWrap.style.display = sched ? "flex" : "none";
document.querySelectorAll('input[name="kp-edit-day"]').forEach(cb => {
cb.checked = sched && sched.days ? sched.days.includes(parseInt(cb.value)) : false;
});
document.getElementById("kp-edit-time-start").value = (sched && sched.time_start) || "";
document.getElementById("kp-edit-time-end").value = (sched && sched.time_end) || "";
document.getElementById("kp-edit-modal").classList.remove("hidden");
});
});
@@ -287,6 +312,12 @@ document.getElementById("btn-new-keypass").addEventListener("click", () => {
// Reset All gates checkbox
document.getElementById("kp-all-gates").checked = true;
checksContainer.style.display = "none";
// Reset schedule
document.getElementById("kp-no-schedule").checked = true;
document.getElementById("kp-schedule-wrap").style.display = "none";
document.querySelectorAll('input[name="kp-day"]').forEach(cb => cb.checked = false);
document.getElementById("kp-time-start").value = "";
document.getElementById("kp-time-end").value = "";
document.getElementById("kp-error").classList.add("hidden");
document.getElementById("keypass-modal").classList.remove("hidden");
});
@@ -310,6 +341,11 @@ document.getElementById("kp-never-expires").addEventListener("change", e => {
kpExpInput.style.opacity = e.target.checked ? ".4" : "";
});
// Schedule toggle
document.getElementById("kp-no-schedule").addEventListener("change", e => {
document.getElementById("kp-schedule-wrap").style.display = e.target.checked ? "none" : "flex";
});
// All gates toggle
document.getElementById("kp-all-gates").addEventListener("change", e => {
const checksContainer = document.getElementById("kp-gate-checks");
@@ -337,6 +373,9 @@ document.getElementById("kp-edit-never").addEventListener("change", e => {
expInput.disabled = e.target.checked;
expInput.style.opacity = e.target.checked ? ".4" : "";
});
document.getElementById("kp-edit-no-schedule").addEventListener("change", e => {
document.getElementById("kp-edit-schedule-wrap").style.display = e.target.checked ? "none" : "flex";
});
document.getElementById("kp-edit-all-gates").addEventListener("change", e => {
const checksContainer = document.getElementById("kp-edit-gate-checks");
if (e.target.checked) {
@@ -368,10 +407,22 @@ document.getElementById("kp-edit-form").addEventListener("submit", async e => {
const expires_at = never ? null : new Date(document.getElementById("kp-edit-expires").value).toISOString();
const allGates = document.getElementById("kp-edit-all-gates").checked;
const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-edit-gate"]:checked')).map(cb => parseInt(cb.value));
const noEditSched = document.getElementById("kp-edit-no-schedule").checked;
let schedule = null;
if (!noEditSched) {
const days = Array.from(document.querySelectorAll('input[name="kp-edit-day"]:checked')).map(cb => parseInt(cb.value));
const time_start = document.getElementById("kp-edit-time-start").value || undefined;
const time_end = document.getElementById("kp-edit-time-end").value || undefined;
schedule = {};
if (days.length) schedule.days = days;
if (time_start) schedule.time_start = time_start;
if (time_end) schedule.time_end = time_end;
if (!Object.keys(schedule).length) schedule = null;
}
const errEl = document.getElementById("kp-edit-error");
errEl.classList.add("hidden");
try {
await api("PATCH", `/api/admin/keypasses/${id}`, { description, expires_at, gate_ids });
await api("PATCH", `/api/admin/keypasses/${id}`, { description, expires_at, gate_ids, schedule });
document.getElementById("kp-edit-modal").classList.add("hidden");
showToast("Keypass updated");
loadKeypasses();
@@ -390,10 +441,22 @@ document.getElementById("keypass-form").addEventListener("submit", async e => {
const expires_at = neverExpires ? null : new Date(document.getElementById("kp-expires").value).toISOString();
const allGates = document.getElementById("kp-all-gates").checked;
const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-gate"]:checked')).map(cb => parseInt(cb.value));
const noSched = document.getElementById("kp-no-schedule").checked;
let schedule = null;
if (!noSched) {
const days = Array.from(document.querySelectorAll('input[name="kp-day"]:checked')).map(cb => parseInt(cb.value));
const time_start = document.getElementById("kp-time-start").value || undefined;
const time_end = document.getElementById("kp-time-end").value || undefined;
schedule = {};
if (days.length) schedule.days = days;
if (time_start) schedule.time_start = time_start;
if (time_end) schedule.time_end = time_end;
if (!Object.keys(schedule).length) schedule = null;
}
const errEl = document.getElementById("kp-error");
errEl.classList.add("hidden");
try {
await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code, charset, length });
await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code, charset, length, schedule });
document.getElementById("keypass-modal").classList.add("hidden");
showToast("Keypass created");
loadKeypasses();