feat(auth): enable local user signup without SMTP, add admin user creation

- Remove SMTP hard-requirement from /api/auth/signup: when SMTP is not
  configured accounts are activated immediately (no email verification).
  When SMTP is configured the existing email-verification flow is kept.
- Inject allow_signup into global template context via app/views/base.py
- Add data-allow-signup attribute to base.html body tag
- Update common.js _renderLoggedOutAuth to show Sign Up (→ /signup) when
  signup is enabled, otherwise Get Started (→ /pricing)
- Add admin API endpoints before the /{user_id:path} catch-all:
    GET  /api/admin/users/local       – list all local accounts
    POST /api/admin/users/local       – admin-create local account (active immediately)
    DELETE /api/admin/users/local/{id} – delete local account + profile
- Add LocalUserCreate / LocalUserResponse Pydantic schemas
- Update admin_users.html with Local User Accounts section and modals
- Update .env.demo to document ALLOW_LOCAL_SIGNUP
- Update docs/BillingSetup.md: SMTP is optional, document both flows
- Update tests: test_signup_smtp_not_configured now asserts 201 + immediate
  activation; add 7 new integration tests for admin local user endpoints

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-07 19:59:10 +00:00
parent 19d8e04566
commit aa6e2fe001
10 changed files with 645 additions and 72 deletions
+303 -1
View File
@@ -23,6 +23,13 @@
>
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Add User Profile
</button>
<button
type="button"
@click="openCreateLocalUserModal()"
class="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
<i class="fas fa-user-lock mr-2" aria-hidden="true"></i> Create Local Account
</button>
</div>
<!-- ── Alert banner ───────────────────────────────────────────────────────── -->
@@ -399,6 +406,200 @@
</div>
</div>
<!-- ── Local Accounts section ────────────────────────────────────────────── -->
<div class="bg-white shadow rounded-lg mt-8">
<div class="px-6 py-4 border-b flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<i class="fas fa-user-lock text-green-600" aria-hidden="true"></i>
Local User Accounts
</h2>
<p class="text-sm text-gray-500 mt-0.5">
Email/password accounts created directly on this server.
</p>
</div>
<button
type="button"
@click="openCreateLocalUserModal()"
class="inline-flex items-center px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
<i class="fas fa-plus mr-1" aria-hidden="true"></i> New Account
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200" aria-label="Local user accounts">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Username</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Display Name</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<template x-if="localUsersLoading">
<tr>
<td colspan="7" class="px-4 py-6 text-center text-gray-400">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> Loading…
</td>
</tr>
</template>
<template x-if="!localUsersLoading && localUsers.length === 0">
<tr>
<td colspan="7" class="px-4 py-6 text-center text-gray-400">
No local accounts yet.
<button type="button" @click="openCreateLocalUserModal()" class="text-green-600 hover:underline ml-1">Create one</button>.
</td>
</tr>
</template>
<template x-for="lu in localUsers" :key="lu.id">
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-mono text-gray-800" x-text="lu.username"></td>
<td class="px-4 py-3 text-sm text-gray-600" x-text="lu.email"></td>
<td class="px-4 py-3 text-sm text-gray-600" x-text="lu.display_name || '—'"></td>
<td class="px-4 py-3 text-sm text-center">
<span
:class="lu.is_active ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
x-text="lu.is_active ? 'Active' : 'Unverified'"
></span>
</td>
<td class="px-4 py-3 text-sm text-center">
<span
:class="lu.is_admin ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
x-text="lu.is_admin ? 'Admin' : 'User'"
></span>
</td>
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap" x-text="lu.created_at ? formatDate(lu.created_at) : '—'"></td>
<td class="px-4 py-3 text-sm text-right">
<button
type="button"
@click="confirmDeleteLocalUser(lu)"
class="text-red-600 hover:text-red-800 focus:outline-none"
:aria-label="`Delete account for ${lu.username}`"
>
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- ── Create local user modal ────────────────────────────────────────────── -->
<div
x-show="localUserModal.open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="create-local-user-title"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg" @click.outside="localUserModal.open = false">
<div class="px-6 py-4 border-b flex items-center justify-between">
<h2 id="create-local-user-title" class="text-lg font-semibold text-gray-900">Create Local Account</h2>
<button type="button" @click="localUserModal.open = false" aria-label="Close" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<form @submit.prevent="submitCreateLocalUser" class="px-6 py-5 space-y-4">
<div>
<label for="lu-email" class="block text-sm font-medium text-gray-700">Email <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="email" id="lu-email" x-model="localUserModal.form.email" required autocomplete="off"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true">
</div>
<div>
<label for="lu-username" class="block text-sm font-medium text-gray-700">Username <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="text" id="lu-username" x-model="localUserModal.form.username" required autocomplete="off"
pattern="^[a-zA-Z0-9_-]+$" minlength="3" maxlength="64"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true" aria-describedby="lu-username-hint">
<p id="lu-username-hint" class="mt-1 text-xs text-gray-500">364 characters. Letters, numbers, hyphens and underscores only.</p>
</div>
<div>
<label for="lu-display-name" class="block text-sm font-medium text-gray-700">Display Name <span class="text-gray-400">(optional)</span></label>
<input type="text" id="lu-display-name" x-model="localUserModal.form.display_name" autocomplete="off" maxlength="255"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;">
</div>
<div>
<label for="lu-password" class="block text-sm font-medium text-gray-700">Password <span aria-hidden="true" class="text-red-500">*</span></label>
<input type="password" id="lu-password" x-model="localUserModal.form.password" required autocomplete="new-password"
minlength="8" maxlength="128"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
style="min-height:40px;" aria-required="true" aria-describedby="lu-password-hint">
<p id="lu-password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="lu-is-admin" x-model="localUserModal.form.is_admin"
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500">
<label for="lu-is-admin" class="text-sm text-gray-700">Grant admin privileges</label>
</div>
<div x-show="localUserModal.error" x-cloak
class="bg-red-50 border-l-4 border-red-500 text-red-700 p-3 rounded text-sm"
role="alert" aria-live="assertive" x-text="localUserModal.error">
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="localUserModal.open = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
<button type="submit" :disabled="localUserModal.saving"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500 disabled:opacity-50">
<span x-show="!localUserModal.saving">Create Account</span>
<span x-show="localUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Creating…</span>
</button>
</div>
</form>
</div>
</div>
<!-- ── Delete local user confirmation modal ───────────────────────────────── -->
<div
x-show="deleteLocalUserModal.open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="delete-local-user-title"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-md" @click.outside="deleteLocalUserModal.open = false">
<div class="px-6 py-4 border-b">
<h2 id="delete-local-user-title" class="text-lg font-semibold text-gray-900">Delete Local Account</h2>
</div>
<div class="px-6 py-5">
<p class="text-sm text-gray-700">
Are you sure you want to delete the account for
<strong class="font-mono" x-text="deleteLocalUserModal.username"></strong>
(<span class="font-mono" x-text="deleteLocalUserModal.email"></span>)?
</p>
<p class="text-sm text-gray-500 mt-2">This cannot be undone. Documents owned by this user are <strong>not</strong> deleted.</p>
</div>
<div class="px-6 py-4 border-t flex justify-end gap-3">
<button type="button" @click="deleteLocalUserModal.open = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
Cancel
</button>
<button type="button" @click="executeDeleteLocalUser()" :disabled="deleteLocalUserModal.deleting"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50">
<span x-show="!deleteLocalUserModal.deleting">Delete Account</span>
<span x-show="deleteLocalUserModal.deleting" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Deleting…</span>
</button>
</div>
</div>
</div>
<!-- ── Delete confirmation modal ──────────────────────────────────────────── -->
<div
x-show="deleteModal.open"
@@ -481,6 +682,23 @@ function adminUsersApp() {
deleting: false,
},
// Local users
localUsers: [],
localUsersLoading: true,
localUserModal: {
open: false,
saving: false,
error: '',
form: { email: '', username: '', display_name: '', password: '', is_admin: false },
},
deleteLocalUserModal: {
open: false,
id: null,
username: '',
email: '',
deleting: false,
},
// Alert
alert: { show: false, type: 'success', title: '', message: '' },
@@ -491,7 +709,7 @@ function adminUsersApp() {
},
async init() {
await this.fetchUsers(1);
await Promise.all([this.fetchUsers(1), this.fetchLocalUsers()]);
},
async fetchUsers(page) {
@@ -633,6 +851,90 @@ function adminUsersApp() {
this.alert = { show: true, type, title, message };
setTimeout(() => { this.alert.show = false; }, type === 'success' ? 5000 : 10000);
},
async fetchLocalUsers() {
this.localUsersLoading = true;
try {
const resp = await fetch('/api/admin/users/local', {
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
});
if (!resp.ok) {
this.showAlert('error', 'Failed to load local users', resp.statusText);
return;
}
this.localUsers = await resp.json();
} catch (e) {
this.showAlert('error', 'Network error', e.message);
} finally {
this.localUsersLoading = false;
}
},
openCreateLocalUserModal() {
this.localUserModal.form = { email: '', username: '', display_name: '', password: '', is_admin: false };
this.localUserModal.error = '';
this.localUserModal.saving = false;
this.localUserModal.open = true;
},
async submitCreateLocalUser() {
this.localUserModal.error = '';
this.localUserModal.saving = true;
try {
const resp = await fetch('/api/admin/users/local', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
body: JSON.stringify(this.localUserModal.form),
});
if (resp.ok) {
this.localUserModal.open = false;
this.showAlert('success', 'Account created', `Local account for "${this.localUserModal.form.username}" was created successfully.`);
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.localUserModal.error = err.detail || 'Failed to create account.';
}
} catch (e) {
this.localUserModal.error = 'Network error: ' + e.message;
} finally {
this.localUserModal.saving = false;
}
},
confirmDeleteLocalUser(lu) {
this.deleteLocalUserModal.id = lu.id;
this.deleteLocalUserModal.username = lu.username;
this.deleteLocalUserModal.email = lu.email;
this.deleteLocalUserModal.deleting = false;
this.deleteLocalUserModal.open = true;
},
async executeDeleteLocalUser() {
this.deleteLocalUserModal.deleting = true;
try {
const resp = await fetch(`/api/admin/users/local/${this.deleteLocalUserModal.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
});
if (resp.status === 204) {
this.deleteLocalUserModal.open = false;
this.showAlert('success', 'Deleted', `Account for "${this.deleteLocalUserModal.username}" has been removed.`);
await this.fetchLocalUsers();
} else {
const err = await resp.json().catch(() => ({}));
this.showAlert('error', 'Delete failed', err.detail || resp.statusText);
this.deleteLocalUserModal.open = false;
}
} catch (e) {
this.showAlert('error', 'Network error', e.message);
this.deleteLocalUserModal.open = false;
} finally {
this.deleteLocalUserModal.deleting = false;
}
},
};
}
</script>