← All Modules

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) and has_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
$}