admin_settings
Database-driven admin panel for user management and role-based access control. Builds on the default ScribeEngine scaffold — no extra dependencies.
- User list with role badges and active/inactive status
- Create, edit, deactivate, and delete users
- Admin password reset for any user
- Self-service password change at
/admin/profile is_admin(session)andhas_role(session, 'rolename')helpers available in every route block and template
Files
Copy each file into your project at the destination path shown. Click a tab to view the file, then use the Copy button.
→ lib/admin_settings_users.py
"""User CRUD helpers for the admin_settings module - auto-loaded into all templates."""
from werkzeug.security import generate_password_hash
def get_all_users(db):
return db['default'].query(
"SELECT id, username, roles, is_active, created_at FROM users ORDER BY username"
)
def get_user_by_id(db, user_id):
rows = db['default'].query("SELECT * FROM users WHERE id = ?", (user_id,))
return rows[0] if rows else None
def create_user(db, username, password, roles='user'):
return db['default'].insert('users',
username=username,
password_hash=generate_password_hash(password),
roles=roles,
is_active=1,
)
def update_user(db, user_id, username, roles):
db['default'].update('users', {'username': username, 'roles': roles}, id=user_id)
def reset_password(db, user_id, new_password):
db['default'].update('users',
{'password_hash': generate_password_hash(new_password)},
id=user_id,
)
def toggle_user_active(db, user_id):
user = get_user_by_id(db, user_id)
if not user:
return None
new_state = 0 if user['is_active'] else 1
db['default'].update('users', {'is_active': new_state}, id=user_id)
return new_state
def delete_user(db, user_id):
db['default'].delete('users', id=user_id)
→ lib/admin_settings_rbac.py
"""Role-based access control helpers for the admin_settings module - auto-loaded into all templates.
Roles are stored as a comma-separated string in users.roles (e.g. 'admin,manager').
On login, mirror the value into session['user_roles'] so these helpers can read it.
"""
def parse_roles(roles_str):
"""Return a list from a comma-separated roles string."""
return [r.strip() for r in (roles_str or '').split(',') if r.strip()]
def format_roles(roles_list):
"""Join a list of roles back into the storage format."""
return ','.join(r.strip() for r in roles_list if r.strip())
def has_role(session, role):
"""Return True if the logged-in user holds the given role."""
return role in parse_roles(session.get('user_roles', ''))
def is_admin(session):
return has_role(session, 'admin')
→ paste into app.stpl
{#
================================================================
ADMIN SETTINGS ROUTES (prefab module: admin_settings)
ScribeEngine discovers routes in all .stpl files, so this file
is live as-is. Copy it (and the other module files) into your
project — no merging into app.stpl required.
Prerequisites:
1. Run migrations/500_admin_settings.sql
2. Copy lib/admin_settings/ into your project
3. Copy static/css/admin_settings/admin.css
4. Add to base.stpl <head>:
<link rel="stylesheet" href="/static/css/admin_settings/admin.css">
5. Update your /login route — see README.md for the two-line change
================================================================
#}
{# ── User list ──────────────────────────────────────────────── #}
@route('/admin')
@require_auth
{$
if not is_admin(session):
return abort(403)
page_title = "User Management"
users = get_all_users(db)
$}
<div class="container">
<div class="admin-header">
<h1>User Management</h1>
<a href="/admin/users/new" class="btn btn-primary">+ New User</a>
</div>
{% if users %}
<div class="card" style="padding:0; overflow:hidden;">
<table class="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Roles</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr class="{{ 'row-inactive' if not u.is_active }}">
<td>
<strong>{{ u.username }}</strong>
{% if u.id == session.user_id %}
<span class="you-badge">you</span>
{% endif %}
</td>
<td>
{% for role in parse_roles(u.roles) %}
<span class="role-badge role-badge-{{ role }}">{{ role }}</span>
{% endfor %}
</td>
<td>
<span class="status-dot {{ 'status-active' if u.is_active else 'status-inactive' }}"></span>
{{ 'Active' if u.is_active else 'Inactive' }}
</td>
<td style="color:var(--text-muted); font-size:var(--font-size-sm)">
{{ u.created_at[:10] if u.created_at else '—' }}
</td>
<td class="action-cell">
<a href="/admin/users/{{ u.id }}/edit" class="btn btn-sm btn-secondary">Edit</a>
{% if u.id != session.user_id %}
<form method="POST" action="/admin/users/{{ u.id }}/toggle" style="display:contents">
{{ csrf() }}
<button class="btn btn-sm {{ 'btn-warning' if u.is_active else 'btn-success' }}">
{{ 'Deactivate' if u.is_active else 'Activate' }}
</button>
</form>
<form method="POST" action="/admin/users/{{ u.id }}/delete" style="display:contents"
onsubmit="return confirm('Delete {{ u.username }}? This cannot be undone.')">
{{ csrf() }}
<button class="btn btn-sm btn-danger">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card">
<p>No users yet. <a href="/admin/users/new">Create the first one.</a></p>
</div>
{% endif %}
</div>
{# ── Create user ─────────────────────────────────────────────── #}
@route('/admin/users/new', methods=['GET', 'POST'])
@require_auth
{$
if not is_admin(session):
return abort(403)
page_title = "Create User"
error = None
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
roles = request.form.get('roles', 'user').strip() or 'user'
if not username:
error = "Username is required."
elif len(password) < 8:
error = "Password must be at least 8 characters."
else:
existing = db['default'].query(
"SELECT id FROM users WHERE username = ?", (username,)
)
if existing:
error = "That username is already taken."
else:
create_user(db, username, password, roles)
flash(f'User "{username}" created.', 'success')
return redirect('/admin')
$}
<div class="container-narrow">
<div class="card">
<h2>Create User</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="POST">
{{ csrf() }}
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username"
value="{{ request.form.get('username', '') }}"
required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
<div class="form-hint">Minimum 8 characters.</div>
</div>
<div class="form-group">
<label for="roles">Roles</label>
<input type="text" id="roles" name="roles"
value="{{ request.form.get('roles', 'user') }}"
placeholder="user">
<div class="form-hint">
Comma-separated, e.g. <code>admin</code> or <code>manager,billing</code>.
Default is <code>user</code>.
</div>
</div>
<div style="display:flex; gap:var(--spacing-md)">
<button type="submit" class="btn btn-primary">Create User</button>
<a href="/admin" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{# ── Edit user ───────────────────────────────────────────────── #}
@route('/admin/users/<int:user_id>/edit', methods=['GET', 'POST'])
@require_auth
{$
if not is_admin(session):
return abort(403)
user = get_user_by_id(db, user_id)
if not user:
return abort(404)
page_title = f"Edit {user['username']}"
error = None
if request.method == 'POST':
action = request.form.get('action')
if action == 'update_info':
new_username = request.form.get('username', '').strip()
new_roles = request.form.get('roles', 'user').strip() or 'user'
if not new_username:
error = "Username is required."
else:
conflict = db['default'].query(
"SELECT id FROM users WHERE username = ? AND id != ?",
(new_username, user_id)
)
if conflict:
error = "That username is already taken."
else:
update_user(db, user_id, new_username, new_roles)
flash('User updated.', 'success')
return redirect('/admin')
elif action == 'reset_password':
new_pw = request.form.get('new_password', '')
confirm = request.form.get('confirm_password', '')
if len(new_pw) < 8:
error = "Password must be at least 8 characters."
elif new_pw != confirm:
error = "Passwords do not match."
else:
reset_password(db, user_id, new_pw)
flash(f'Password for "{user["username"]}" reset.', 'success')
return redirect('/admin')
user = get_user_by_id(db, user_id)
$}
<div class="container-narrow">
<p><a href="/admin">← User Management</a></p>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<div class="card">
<h2>Edit User</h2>
<form method="POST">
{{ csrf() }}
<input type="hidden" name="action" value="update_info">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username"
value="{{ request.form.get('username', user.username) }}"
required autofocus>
</div>
<div class="form-group">
<label for="roles">Roles</label>
<input type="text" id="roles" name="roles"
value="{{ request.form.get('roles', user.roles) }}"
placeholder="user">
<div class="form-hint">
Comma-separated, e.g. <code>admin</code> or <code>manager,billing</code>.
</div>
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
<div class="admin-section">
<h3>Reset Password</h3>
<form method="POST">
{{ csrf() }}
<input type="hidden" name="action" value="reset_password">
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" required>
<div class="form-hint">Minimum 8 characters.</div>
</div>
<div class="form-group">
<label for="confirm_password">Confirm Password</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<button type="submit" class="btn btn-warning">Reset Password</button>
</form>
</div>
</div>
</div>
{# ── Toggle active/inactive ──────────────────────────────────── #}
@route('/admin/users/<int:user_id>/toggle', methods=['POST'])
@require_auth
{$
if not is_admin(session):
return abort(403)
if user_id == session['user_id']:
flash("You cannot deactivate your own account.", 'error')
return redirect('/admin')
new_state = toggle_user_active(db, user_id)
flash('User activated.' if new_state else 'User deactivated.', 'success')
return redirect('/admin')
$}
{# ── Delete user ─────────────────────────────────────────────── #}
@route('/admin/users/<int:user_id>/delete', methods=['POST'])
@require_auth
{$
if not is_admin(session):
return abort(403)
if user_id == session['user_id']:
flash("You cannot delete your own account.", 'error')
return redirect('/admin')
user = get_user_by_id(db, user_id)
if user:
delete_user(db, user_id)
flash(f'User "{user["username"]}" deleted.', 'success')
return redirect('/admin')
$}
{# ── Self-service profile (any logged-in user) ───────────────── #}
@route('/admin/profile', methods=['GET', 'POST'])
@require_auth
{$
page_title = "My Profile"
error = None
user = get_user_by_id(db, session['user_id'])
if request.method == 'POST':
current_pw = request.form.get('current_password', '')
new_pw = request.form.get('new_password', '')
confirm = request.form.get('confirm_password', '')
if not verify_password(user['password_hash'], current_pw):
error = "Current password is incorrect."
elif len(new_pw) < 8:
error = "New password must be at least 8 characters."
elif new_pw != confirm:
error = "Passwords do not match."
else:
reset_password(db, session['user_id'], new_pw)
flash('Password updated successfully.', 'success')
return redirect('/admin/profile')
$}
<div class="container-narrow">
<div class="card">
<h2>My Profile</h2>
<div style="margin-bottom:var(--spacing-xl); padding-bottom:var(--spacing-xl); border-bottom:1px solid var(--border-color)">
<p><strong>Username:</strong> {{ user.username }}</p>
<p><strong>Roles:</strong>
{% for role in parse_roles(user.roles) %}
<span class="role-badge role-badge-{{ role }}">{{ role }}</span>
{% endfor %}
</p>
<p style="color:var(--text-muted); font-size:var(--font-size-sm)">
Member since {{ user.created_at[:10] if user.created_at else '—' }}
</p>
</div>
<h3 style="margin-bottom:var(--spacing-lg)">Change Password</h3>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="POST">
{{ csrf() }}
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" id="current_password" name="current_password" required autofocus>
</div>
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" required>
<div class="form-hint">Minimum 8 characters.</div>
</div>
<div class="form-group">
<label for="confirm_password">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<button type="submit" class="btn btn-primary">Update Password</button>
</form>
</div>
</div>
→ migrations/500_admin_settings.sql
-- admin_settings module: adds multi-role support and soft-disable to users
ALTER TABLE users ADD COLUMN roles TEXT NOT NULL DEFAULT 'user';
ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;
-- Ensure an 'admin' user exists with the admin role.
-- Default credentials: admin / changeme — change the password immediately after first login.
INSERT INTO users (username, password_hash, roles, is_active)
SELECT 'admin',
'scrypt:32768:8:1$IwCIN1tmL0XWgF4l$a767b0347682cef997c8932a097ee15756bbc4ef07664d747918ba95077d71b45ddd9d781d2753310355e359f3b7d151a89d8b8d922062a261b3494f9813ef13',
'admin',
1
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'admin');
-- If an 'admin' user already existed, make sure it has the admin role.
UPDATE users SET roles = 'admin' WHERE username = 'admin';
→ static/css/admin_settings/admin.css
/* ================================================================
ADMIN SETTINGS MODULE — CSS
Add a link to this file in your base.stpl <head>:
<link rel="stylesheet" href="/static/css/admin_settings/admin.css">
================================================================ */
/* ── Layout helpers ─────────────────────────────────────────── */
.container-narrow {
max-width: 600px;
margin: 0 auto;
padding: 0 var(--spacing-xl);
}
.card {
background: var(--bg-surface);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
}
/* ── Page header: title left, action button right ───────────── */
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.admin-header h1 {
margin-bottom: 0;
}
/* ── User table ─────────────────────────────────────────────── */
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th {
text-align: left;
padding: var(--spacing-md) var(--spacing-lg);
background: var(--bg-surface);
border-bottom: 2px solid var(--color-border);
font-size: 0.8rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
}
.admin-table td {
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
.admin-table tbody tr:last-child td {
border-bottom: none;
}
.admin-table tbody tr:hover {
background: var(--bg-surface);
}
.admin-table tr.row-inactive td {
opacity: 0.5;
}
/* ── Status dot ─────────────────────────────────────────────── */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
position: relative;
top: -1px;
}
.status-active { background: var(--color-success); }
.status-inactive { background: var(--text-muted); }
/* ── Role badges ────────────────────────────────────────────── */
.role-badge {
display: inline-block;
padding: 2px 9px;
font-size: 0.72rem;
font-weight: 600;
background: rgba(229, 192, 123, 0.1);
color: var(--color-primary);
border: var(--border-width) solid rgba(229, 192, 123, 0.3);
margin-right: 4px;
white-space: nowrap;
}
.role-badge-admin {
background: rgba(224, 108, 117, 0.1);
color: var(--color-danger);
border-color: rgba(224, 108, 117, 0.3);
}
/* "you" label next to the current user's name */
.you-badge {
display: inline-block;
padding: 1px 7px;
font-size: 0.7rem;
font-weight: 600;
background: rgba(229, 192, 123, 0.1);
color: var(--color-primary);
border: var(--border-width) solid rgba(229, 192, 123, 0.3);
margin-left: 6px;
vertical-align: middle;
}
/* ── Small button modifier ──────────────────────────────────── */
.btn-sm {
padding: 3px 10px;
font-size: 0.8rem;
}
/* ── Semantic button variants ───────────────────────────────── */
.btn-danger {
color: var(--color-danger);
border-color: var(--color-danger);
}
.btn-danger:hover {
background: var(--color-danger);
color: var(--bg-base);
text-decoration: none;
}
.btn-warning {
color: var(--color-warning);
border-color: var(--color-warning);
}
.btn-warning:hover {
background: var(--color-warning);
color: var(--bg-base);
text-decoration: none;
}
.btn-success {
color: var(--color-success);
border-color: var(--color-success);
}
.btn-success:hover {
background: var(--color-success);
color: var(--bg-base);
text-decoration: none;
}
/* ── Table action cell ──────────────────────────────────────── */
.action-cell {
display: flex;
gap: var(--spacing-xs);
align-items: center;
flex-wrap: wrap;
}
/* ── Edit form section divider ──────────────────────────────── */
.admin-section {
margin-top: var(--spacing-xl);
padding-top: var(--spacing-xl);
border-top: 1px solid var(--color-border);
}
.admin-section h3 {
margin-bottom: var(--spacing-lg);
color: var(--text-muted);
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Form hint text ─────────────────────────────────────────── */
.form-hint {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: var(--spacing-xs);
}
Installation
1. Run the migration
scribe migrate
Adds roles and is_active columns to the users table.
2. Add the stylesheet to base.stpl
<link rel="stylesheet" href="/static/css/admin_settings/admin.css">
3. Update the /login route in app.stpl
After a successful password check, store the user's roles in session and block inactive accounts:
if verify_password(user['password_hash'], password):
if not user['is_active']:
error = "Account is disabled. Contact an administrator."
else:
session['user_id'] = user['id']
session['user_roles'] = user['roles'] # ← add this line
flash('Login successful!', 'success')
return redirect('/dashboard')
else:
error = "Invalid password"
4. (Optional) Add an Admin link to your navbar in base.stpl
{% if is_admin is defined and is_admin(session) %}
<a href="/admin">Admin</a>
{% endif %}
Role model
Roles are stored as a comma-separated string on the users row — no junction tables.
Any string is a valid role. Check from a route block:
has_role(session, 'manager') # True/False
is_admin(session) # shorthand for has_role(session, 'admin')
From a Jinja2 template:
{% if has_role(session, 'billing') %}
<a href="/invoices">Invoices</a>
{% endif %}
Protecting a route by role
@route('/reports')
@require_auth
{$
if not has_role(session, 'manager'):
return abort(403)
# ... route logic
$}