feat(auth): add an admin-managed login allowlist

Lets the admin restrict which pubkeys may log in, enforced server-side
at /api/auth/login before a session is issued. Disabled by default;
the admin and the bootstrap (no-admin-claimed-yet) case always pass.
Manageable via the existing settings UI/API (npub or hex, one per line).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 18:33:21 +00:00
co-authored by Claude Sonnet 5
parent b4c2214c7a
commit 69241a28b1
5 changed files with 164 additions and 2 deletions
+56 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { nip19 } from 'nostr-tools';
import { api } from '../lib/api';
import { useAuthStore } from '../stores/auth';
@@ -8,28 +9,64 @@ interface Settings {
relays: string[];
public_url: string;
admin_pubkey: string | null;
login_allowlist_enabled: boolean;
login_allowlist: string[];
}
const auth = useAuthStore();
const form = reactive({ blossom_url: '', relays: '', public_url: '' });
const form = reactive({
blossom_url: '',
relays: '',
public_url: '',
login_allowlist_enabled: false,
login_allowlist: '',
});
const saved = ref(false);
const error = ref('');
/** Accepts npub or raw hex, one per line; returns lowercase hex. Throws on anything invalid. */
function parseAllowlist(text: string): string[] {
return text
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((line) => {
if (line.startsWith('npub1')) {
const decoded = nip19.decode(line);
if (decoded.type !== 'npub') throw new Error(`not an npub: ${line}`);
return decoded.data;
}
if (!/^[0-9a-f]{64}$/i.test(line)) throw new Error(`not a valid npub or hex pubkey: ${line}`);
return line.toLowerCase();
});
}
onMounted(async () => {
const s = await api.get<Settings>('/api/settings');
form.blossom_url = s.blossom_url;
form.relays = s.relays.join('\n');
form.public_url = s.public_url;
form.login_allowlist_enabled = s.login_allowlist_enabled;
form.login_allowlist = s.login_allowlist.map((pk) => nip19.npubEncode(pk)).join('\n');
});
async function save() {
error.value = '';
saved.value = false;
let allowlist: string[];
try {
allowlist = parseAllowlist(form.login_allowlist);
} catch (err) {
error.value = (err as Error).message;
return;
}
try {
await api.put('/api/settings', {
blossom_url: form.blossom_url,
relays: form.relays.split('\n').map((r) => r.trim()).filter(Boolean),
public_url: form.public_url,
login_allowlist_enabled: form.login_allowlist_enabled,
login_allowlist: allowlist,
});
saved.value = true;
} catch (err) {
@@ -62,6 +99,24 @@ async function save() {
<input id="set-public" v-model="form.public_url" class="input" type="url" required />
<p class="mt-1 text-xs text-white/30">Used in RSS feed links. Must be reachable by podcast apps.</p>
</div>
<div class="border-t border-white/10 pt-4">
<label class="flex items-center gap-2 text-sm">
<input v-model="form.login_allowlist_enabled" type="checkbox" />
Restrict logins to an allowlist
</label>
<p class="mt-1 text-xs text-white/30">
When enabled, only the admin and pubkeys listed below can log in. Everyone else is
rejected at login (existing sessions aren't revoked).
</p>
<textarea
id="set-allowlist"
v-model="form.login_allowlist"
class="input mt-2 font-mono text-sm"
rows="6"
placeholder="npub1... (one per line, npub or hex)"
:disabled="!form.login_allowlist_enabled"
/>
</div>
<p v-if="!auth.isAdmin" class="rounded-lg bg-amber-500/20 border border-amber-500/40 p-3 text-sm text-amber-200">
Only the admin (first account to log in) can change settings.
</p>