feat: add scoped API keys for programmatic site access

Introduce ApiKey model, CRUD endpoints, and admin UI so agents can
authenticate with permission-scoped keys. Normalize pubkeys to hex on login,
dedupe legacy npub/hex user rows, and ignore .cursor in git.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bbe
2026-06-23 09:29:30 +02:00
co-authored by Cursor
parent 70e3e0633d
commit a6a2b113ee
17 changed files with 836 additions and 100 deletions
+29 -25
View File
@@ -45,7 +45,7 @@ export default function RolesPage() {
const [original, setOriginal] = useState<Matrix>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [savingRole, setSavingRole] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState("");
const load = async () => {
@@ -101,19 +101,26 @@ export default function RolesPage() {
}));
};
const saveRole = async (role: string) => {
setSavingRole(role);
const saveAll = async () => {
if (dirtyRoles.length === 0) return;
setSaving(true);
setError("");
setNotice("");
try {
const keys = permissions.filter((p) => matrix[role]?.[p.key]).map((p) => p.key);
await api.updateRolePermissions(role, keys);
setOriginal((prev) => ({ ...prev, [role]: { ...matrix[role] } }));
setNotice(`${ROLE_LABELS[role] ?? role} permissions saved.`);
for (const role of dirtyRoles) {
const keys = permissions.filter((p) => matrix[role]?.[p.key]).map((p) => p.key);
await api.updateRolePermissions(role, keys);
}
setOriginal(() => {
const next: Matrix = {};
for (const role of roles) next[role] = { ...matrix[role] };
return next;
});
setNotice("Permissions saved.");
} catch (err: any) {
setError(err.message);
} finally {
setSavingRole(null);
setSaving(false);
}
};
@@ -207,23 +214,20 @@ export default function RolesPage() {
</table>
</div>
<div className="flex flex-wrap gap-3">
{roles.map((role) => {
const dirty = dirtyRoles.includes(role);
return (
<button
key={role}
onClick={() => saveRole(role)}
disabled={!dirty || savingRole === role}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={16} />
{savingRole === role
? "Saving..."
: `Save ${ROLE_LABELS[role] ?? role}${dirty ? " *" : ""}`}
</button>
);
})}
<div className="flex flex-wrap items-center gap-3">
<button
onClick={saveAll}
disabled={dirtyRoles.length === 0 || saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={16} />
{saving ? "Saving..." : "Save changes"}
</button>
{dirtyRoles.length > 0 && !saving && (
<span className="text-on-surface/50 text-sm">
Unsaved changes to {dirtyRoles.map((r) => ROLE_LABELS[r] ?? r).join(", ")}
</span>
)}
</div>
</div>
);