Add passphrase keypasses
This commit is contained in:
@@ -30,6 +30,9 @@ class KeypassCreate(BaseModel):
|
|||||||
expires_at: Optional[datetime] = None # None = never expires
|
expires_at: Optional[datetime] = None # None = never expires
|
||||||
gate_ids: list[int] = [] # empty = all gates
|
gate_ids: list[int] = [] # empty = all gates
|
||||||
code: Optional[str] = None # None = auto-generate
|
code: Optional[str] = None # None = auto-generate
|
||||||
|
# Auto-generation options (ignored when `code` is supplied manually)
|
||||||
|
length: int = 12 # 6–32
|
||||||
|
charset: str = "alphanumeric" # "alphanumeric" | "alpha" | "numeric" | "passphrase"
|
||||||
|
|
||||||
|
|
||||||
class KeypassPatch(BaseModel):
|
class KeypassPatch(BaseModel):
|
||||||
|
|||||||
@@ -16,9 +16,66 @@ from core.schemas import KeypassCreate, KeypassPatch, KeypassResponse, keypass_t
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/keypasses", tags=["admin-keypasses"])
|
router = APIRouter(prefix="/api/admin/keypasses", tags=["admin-keypasses"])
|
||||||
|
|
||||||
|
# ── Word list for passphrase mode ─────────────────────────────────────────────
|
||||||
|
_WORDS = [
|
||||||
|
"apple", "beach", "brick", "brush", "cabin", "calm", "cedar", "chain",
|
||||||
|
"chalk", "chase", "chest", "clear", "cliff", "clock", "cloud", "coast",
|
||||||
|
"coral", "crane", "creek", "crisp", "crown", "curve", "dance", "delta",
|
||||||
|
"depot", "drift", "drive", "drops", "dunes", "eagle", "earth", "ember",
|
||||||
|
"fence", "field", "final", "flame", "flash", "fleet", "flint", "floor",
|
||||||
|
"focus", "forge", "forth", "frost", "fruit", "glade", "gleam", "globe",
|
||||||
|
"gloom", "gloss", "grace", "grain", "grand", "grape", "grass", "gravel",
|
||||||
|
"green", "grove", "guard", "guide", "haven", "heart", "honey", "honor",
|
||||||
|
"hatch", "image", "inlet", "ivory", "joker", "karma", "knoll", "lake",
|
||||||
|
"lance", "large", "laser", "latch", "layer", "ledge", "light", "linen",
|
||||||
|
"links", "liver", "lunar", "mango", "maple", "march", "marsh", "merit",
|
||||||
|
"metal", "minor", "mirth", "misty", "mixer", "mocha", "model", "money",
|
||||||
|
"mount", "mouse", "named", "nerve", "night", "noble", "north", "notch",
|
||||||
|
"novel", "oaken", "ocean", "olive", "ombre", "ozone", "panel", "paper",
|
||||||
|
"patch", "pause", "peace", "pearl", "petal", "pilot", "pinch", "pixel",
|
||||||
|
"plain", "plane", "plaza", "plumb", "plume", "plush", "polar", "porch",
|
||||||
|
"power", "prism", "prize", "probe", "proxy", "pulse", "quail", "quartz",
|
||||||
|
"queen", "quest", "queue", "quiet", "quota", "quote", "radar", "radix",
|
||||||
|
"rally", "ranch", "rapid", "raven", "reach", "realm", "relay", "repay",
|
||||||
|
"resin", "ridge", "rivet", "river", "rogue", "round", "route", "rover",
|
||||||
|
"royal", "rusty", "saint", "salsa", "salvo", "sandy", "score", "scout",
|
||||||
|
"serum", "shade", "shaft", "shale", "shall", "shape", "sharp", "sheen",
|
||||||
|
"shelf", "shell", "shift", "shiny", "shore", "short", "sigma", "silky",
|
||||||
|
"silva", "since", "sixth", "slate", "slope", "smoke", "solar", "solid",
|
||||||
|
"sonic", "sound", "south", "space", "spark", "spear", "spend", "spire",
|
||||||
|
"split", "spoke", "spore", "sport", "spray", "spree", "sprig", "squad",
|
||||||
|
"stack", "staff", "stage", "stain", "stake", "stale", "stall", "stamp",
|
||||||
|
"stand", "stark", "start", "state", "stays", "steel", "steep", "steer",
|
||||||
|
"stern", "stock", "stone", "storm", "story", "stove", "strap", "straw",
|
||||||
|
"strip", "strut", "study", "stuff", "sugar", "suite", "sunny", "surge",
|
||||||
|
"swamp", "swarm", "swept", "swing", "sword", "synth", "table", "talon",
|
||||||
|
"tango", "taste", "tawny", "tempo", "thatch", "theme", "think", "thorn",
|
||||||
|
"three", "threw", "tiger", "tidal", "titan", "token", "topaz", "torch",
|
||||||
|
"total", "touch", "tough", "towel", "tower", "trace", "track", "trade",
|
||||||
|
"trail", "train", "trait", "tramp", "trawl", "trend", "trial", "tribe",
|
||||||
|
"trick", "tried", "troop", "trove", "truce", "trunk", "trust", "tuber",
|
||||||
|
"tundra", "tuner", "twill", "twist", "ultra", "umbra", "uncle", "union",
|
||||||
|
"unity", "until", "upper", "urged", "usage", "utmost", "valor", "vault",
|
||||||
|
"vibes", "vigor", "viper", "vista", "vital", "vivid", "voice", "voter",
|
||||||
|
"vroom", "waltz", "water", "waves", "wedge", "weird", "wheat", "wheel",
|
||||||
|
"where", "whirl", "white", "whole", "widow", "width", "winds", "windy",
|
||||||
|
"witch", "world", "wreck", "wrist", "xerox", "yacht", "yards", "years",
|
||||||
|
"yield", "young", "youth", "zeal", "zebra", "zones", "zenith",
|
||||||
|
]
|
||||||
|
|
||||||
def _generate_code(length: int = 12) -> str:
|
_CHARSETS = {
|
||||||
alphabet = string.ascii_uppercase + string.digits
|
"alphanumeric": string.ascii_uppercase + string.digits,
|
||||||
|
"alpha": string.ascii_uppercase,
|
||||||
|
"numeric": string.digits,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_code(length: int = 12, charset: str = "alphanumeric") -> str:
|
||||||
|
if charset == "passphrase":
|
||||||
|
# 4 random words joined by hyphens; `length` is ignored for passphrases
|
||||||
|
return "-".join(secrets.choice(_WORDS).upper() for _ in range(4))
|
||||||
|
alphabet = _CHARSETS.get(charset, _CHARSETS["alphanumeric"])
|
||||||
|
length = max(6, min(length, 32))
|
||||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +92,7 @@ async def create_keypass(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: dict = Depends(require_manager),
|
_: dict = Depends(require_manager),
|
||||||
):
|
):
|
||||||
code = req.code.strip().upper() if req.code and req.code.strip() else _generate_code()
|
code = req.code.strip().upper() if req.code and req.code.strip() else _generate_code(req.length, req.charset)
|
||||||
if db.query(Keypass).filter(Keypass.code == code).first():
|
if db.query(Keypass).filter(Keypass.code == code).first():
|
||||||
raise HTTPException(409, "A keypass with this code already exists")
|
raise HTTPException(409, "A keypass with this code already exists")
|
||||||
kp = Keypass(
|
kp = Keypass(
|
||||||
@@ -112,6 +169,6 @@ async def keypass_qr(
|
|||||||
img = qr.make_image(fill_color="black", back_color="white")
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
img.save(buf, format="PNG")
|
img.save(buf)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return Response(content=buf.read(), media_type="image/png")
|
return Response(content=buf.read(), media_type="image/png")
|
||||||
|
|||||||
@@ -302,7 +302,23 @@
|
|||||||
<label for="kp-code">Code <span style="color:var(--text-muted);font-weight:400">(leave empty to auto-generate)</span></label>
|
<label for="kp-code">Code <span style="color:var(--text-muted);font-weight:400">(leave empty to auto-generate)</span></label>
|
||||||
<input id="kp-code" type="text" autocomplete="off" autocorrect="off" autocapitalize="characters"
|
<input id="kp-code" type="text" autocomplete="off" autocorrect="off" autocapitalize="characters"
|
||||||
spellcheck="false" placeholder="Auto-generated"
|
spellcheck="false" placeholder="Auto-generated"
|
||||||
style="font-family:monospace;letter-spacing:.1em;text-transform:uppercase" maxlength="32" />
|
style="font-family:monospace;letter-spacing:.1em;text-transform:uppercase" maxlength="40" />
|
||||||
|
</div>
|
||||||
|
<!-- Auto-generation options — hidden when a manual code is typed -->
|
||||||
|
<div id="kp-autogen-options" class="field" style="background:var(--surface2);border-radius:8px;padding:.75rem;border:1px solid var(--border);display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label for="kp-charset" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Character set</label>
|
||||||
|
<select id="kp-charset" style="width:auto;font-size:.9rem">
|
||||||
|
<option value="alphanumeric">A–Z + 0–9</option>
|
||||||
|
<option value="alpha">A–Z only</option>
|
||||||
|
<option value="numeric">0–9 only</option>
|
||||||
|
<option value="passphrase" selected>Passphrase (4 words)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="kp-length-wrap" style="display:flex;flex-direction:column;gap:.3rem">
|
||||||
|
<label for="kp-length" style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Length <span id="kp-length-val" style="font-weight:700;color:var(--text)">12</span></label>
|
||||||
|
<input id="kp-length" type="range" min="6" max="32" value="12" style="width:100%;cursor:pointer" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" id="kp-expires-field">
|
<div class="field" id="kp-expires-field">
|
||||||
<label for="kp-expires">Expiry date & time</label>
|
<label for="kp-expires">Expiry date & time</label>
|
||||||
|
|||||||
@@ -236,6 +236,12 @@ let _allGates = [];
|
|||||||
document.getElementById("btn-new-keypass").addEventListener("click", () => {
|
document.getElementById("btn-new-keypass").addEventListener("click", () => {
|
||||||
document.getElementById("kp-desc").value = "";
|
document.getElementById("kp-desc").value = "";
|
||||||
document.getElementById("kp-code").value = "";
|
document.getElementById("kp-code").value = "";
|
||||||
|
// Reset complexity — default to passphrase
|
||||||
|
document.getElementById("kp-charset").value = "passphrase";
|
||||||
|
document.getElementById("kp-length").value = 12;
|
||||||
|
document.getElementById("kp-length-val").textContent = "12";
|
||||||
|
document.getElementById("kp-length-wrap").style.display = "none";
|
||||||
|
document.getElementById("kp-autogen-options").style.display = "";
|
||||||
// Reset never-expires
|
// Reset never-expires
|
||||||
const neverCb = document.getElementById("kp-never-expires");
|
const neverCb = document.getElementById("kp-never-expires");
|
||||||
neverCb.checked = false;
|
neverCb.checked = false;
|
||||||
@@ -263,6 +269,17 @@ document.getElementById("btn-new-keypass").addEventListener("click", () => {
|
|||||||
document.getElementById("keypass-modal").classList.remove("hidden");
|
document.getElementById("keypass-modal").classList.remove("hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-generation options — hide when a manual code is typed, hide length for passphrase
|
||||||
|
document.getElementById("kp-code").addEventListener("input", e => {
|
||||||
|
document.getElementById("kp-autogen-options").style.display = e.target.value.trim() ? "none" : "";
|
||||||
|
});
|
||||||
|
document.getElementById("kp-charset").addEventListener("change", e => {
|
||||||
|
document.getElementById("kp-length-wrap").style.display = e.target.value === "passphrase" ? "none" : "";
|
||||||
|
});
|
||||||
|
document.getElementById("kp-length").addEventListener("input", e => {
|
||||||
|
document.getElementById("kp-length-val").textContent = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
// Never expires toggle
|
// Never expires toggle
|
||||||
document.getElementById("kp-never-expires").addEventListener("change", e => {
|
document.getElementById("kp-never-expires").addEventListener("change", e => {
|
||||||
const kpExpInput = document.getElementById("kp-expires");
|
const kpExpInput = document.getElementById("kp-expires");
|
||||||
@@ -345,6 +362,8 @@ document.getElementById("keypass-form").addEventListener("submit", async e => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const desc = document.getElementById("kp-desc").value.trim();
|
const desc = document.getElementById("kp-desc").value.trim();
|
||||||
const code = document.getElementById("kp-code").value.trim() || null;
|
const code = document.getElementById("kp-code").value.trim() || null;
|
||||||
|
const charset = document.getElementById("kp-charset").value;
|
||||||
|
const length = parseInt(document.getElementById("kp-length").value, 10);
|
||||||
const neverExpires = document.getElementById("kp-never-expires").checked;
|
const neverExpires = document.getElementById("kp-never-expires").checked;
|
||||||
const expires_at = neverExpires ? null : new Date(document.getElementById("kp-expires").value).toISOString();
|
const expires_at = neverExpires ? null : new Date(document.getElementById("kp-expires").value).toISOString();
|
||||||
const allGates = document.getElementById("kp-all-gates").checked;
|
const allGates = document.getElementById("kp-all-gates").checked;
|
||||||
@@ -352,7 +371,7 @@ document.getElementById("keypass-form").addEventListener("submit", async e => {
|
|||||||
const errEl = document.getElementById("kp-error");
|
const errEl = document.getElementById("kp-error");
|
||||||
errEl.classList.add("hidden");
|
errEl.classList.add("hidden");
|
||||||
try {
|
try {
|
||||||
await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code });
|
await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code, charset, length });
|
||||||
document.getElementById("keypass-modal").classList.add("hidden");
|
document.getElementById("keypass-modal").classList.add("hidden");
|
||||||
showToast("Keypass created");
|
showToast("Keypass created");
|
||||||
loadKeypasses();
|
loadKeypasses();
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
autocapitalize="characters"
|
autocapitalize="characters"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
placeholder="Enter keypass…"
|
placeholder="Enter keypass…"
|
||||||
maxlength="20"
|
maxlength="40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p id="login-error" class="error-msg hidden"></p>
|
<p id="login-error" class="error-msg hidden"></p>
|
||||||
|
|||||||
Reference in New Issue
Block a user