- Proto service renamed from EngPadSync to EngPadSyncService per buf STANDARD lint rule SERVICE_SUFFIX - Added java_package and java_multiple_files options for Android client - Added buf.yaml with STANDARD lint and FILE breaking detection - Regenerated Go gRPC stubs, updated server references Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
136 lines
5.0 KiB
HTML
136 lines
5.0 KiB
HTML
{{define "title"}}Security Keys — Engineering Pad{{end}}
|
|
|
|
{{define "nav-right"}}<a href="/logout">Logout</a>{{end}}
|
|
|
|
{{define "content"}}
|
|
<h1>Security Keys</h1>
|
|
|
|
<p><a href="/notebooks" class="btn">Back to Notebooks</a></p>
|
|
|
|
{{if .Keys}}
|
|
<table style="width:100%; max-width:600px; border-collapse:collapse; margin:1rem 0;">
|
|
<thead>
|
|
<tr style="border-bottom:2px solid #111; text-align:left;">
|
|
<th style="padding:0.5rem;">Name</th>
|
|
<th style="padding:0.5rem;">Registered</th>
|
|
<th style="padding:0.5rem;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .Keys}}
|
|
<tr style="border-bottom:1px solid #ccc;">
|
|
<td style="padding:0.5rem;">{{.Name}}</td>
|
|
<td style="padding:0.5rem;">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
|
<td style="padding:0.5rem;">
|
|
<form method="POST" action="/keys/delete" style="display:inline;"
|
|
onsubmit="return confirm('Delete this key?');">
|
|
<input type="hidden" name="id" value="{{.ID}}">
|
|
<button type="submit" class="btn" style="color:red; border-color:red;">Delete</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{else}}
|
|
<p style="margin:1rem 0;">No security keys registered.</p>
|
|
{{end}}
|
|
|
|
{{if .WebAuthnEnabled}}
|
|
<div style="margin-top:1.5rem;">
|
|
<h2 style="font-size:1.2rem;">Register New Key</h2>
|
|
<div class="form-group">
|
|
<label for="key-name">Key Name</label>
|
|
<input type="text" id="key-name" placeholder="e.g. YubiKey 5" value="">
|
|
</div>
|
|
<button type="button" class="btn" id="register-btn">Register Security Key</button>
|
|
<p id="register-status" style="margin-top:0.5rem;"></p>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
if (!window.PublicKeyCredential) {
|
|
document.getElementById("register-btn").style.display = "none";
|
|
document.getElementById("register-status").textContent = "WebAuthn is not supported in this browser.";
|
|
return;
|
|
}
|
|
|
|
var btn = document.getElementById("register-btn");
|
|
var statusEl = document.getElementById("register-status");
|
|
|
|
btn.addEventListener("click", async function() {
|
|
statusEl.textContent = "";
|
|
statusEl.style.color = "";
|
|
btn.disabled = true;
|
|
btn.textContent = "Waiting for security key...";
|
|
|
|
try {
|
|
var beginResp = await fetch("/webauthn/register/begin", {method: "POST"});
|
|
if (!beginResp.ok) {
|
|
var err = await beginResp.json();
|
|
throw new Error(err.error || "Registration failed");
|
|
}
|
|
var options = await beginResp.json();
|
|
|
|
// Decode base64url fields.
|
|
options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge);
|
|
options.publicKey.user.id = base64urlToBuffer(options.publicKey.user.id);
|
|
if (options.publicKey.excludeCredentials) {
|
|
options.publicKey.excludeCredentials.forEach(function(c) {
|
|
c.id = base64urlToBuffer(c.id);
|
|
});
|
|
}
|
|
|
|
var credential = await navigator.credentials.create({publicKey: options.publicKey});
|
|
|
|
var body = JSON.stringify({
|
|
id: credential.id,
|
|
rawId: bufferToBase64url(credential.rawId),
|
|
type: credential.type,
|
|
response: {
|
|
attestationObject: bufferToBase64url(credential.response.attestationObject),
|
|
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON)
|
|
}
|
|
});
|
|
|
|
var keyName = document.getElementById("key-name").value || "Security Key";
|
|
var finishResp = await fetch("/webauthn/register/finish?name=" + encodeURIComponent(keyName), {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: body
|
|
});
|
|
if (!finishResp.ok) {
|
|
var err2 = await finishResp.json();
|
|
throw new Error(err2.error || "Registration failed");
|
|
}
|
|
|
|
window.location.reload();
|
|
} catch(e) {
|
|
statusEl.style.color = "red";
|
|
statusEl.textContent = e.message || "Registration failed";
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = "Register Security Key";
|
|
}
|
|
});
|
|
|
|
function base64urlToBuffer(b64url) {
|
|
var b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
|
|
while (b64.length % 4) b64 += "=";
|
|
var binary = atob(b64);
|
|
var bytes = new Uint8Array(binary.length);
|
|
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
return bytes.buffer;
|
|
}
|
|
|
|
function bufferToBase64url(buf) {
|
|
var bytes = new Uint8Array(buf);
|
|
var binary = "";
|
|
for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
}
|
|
})();
|
|
</script>
|
|
{{end}}
|
|
{{end}}
|