Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions flask_setup/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,20 @@ def _register_auth_routes(app: Flask, adapters: dict) -> None:
endpoint="auth.change_role",
)

app.add_url_rule(
"/admin/users/<int:account_id>/ban",
view_func=acc.ban_account,
methods=["POST"],
endpoint="auth.ban_account",
)

app.add_url_rule(
"/admin/users/<int:account_id>/unban",
view_func=acc.unban_account,
methods=["POST"],
endpoint="auth.unban_account",
)


def _register_file_routes(app: Flask, adapters: dict) -> None:
fad = adapters["file_adapter"]
Expand Down
56 changes: 56 additions & 0 deletions frontend/static/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,62 @@ button:hover,
margin: 0;
}

#ban-modal {
max-width: 32rem;
}

#ban-modal .modal-input-group {
gap: 0;
}

#ban-modal .modal-input-group label {
margin-bottom: var(--spacing-sm);
font-family: var(--font-primary);
}

#ban-modal .char-count {
font-family: var(--font-primary);
}

#ban-modal .ban-reason-textarea {
margin-top: var(--spacing-xs);
}

.ban-reason-textarea {
width: 100%;
min-height: 4.5rem;
height: auto;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--outline-variant);
border-radius: var(--radius-default);
background: var(--surface-container);
color: var(--on-surface);
font-family: var(--font-primary);
font-size: var(--font-body-base);
line-height: 1.5;
resize: vertical;
outline: none;
box-sizing: border-box;
}

.ban-reason-textarea:focus {
border-color: var(--primary);
}

.hint-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--font-body-sm);
color: var(--on-surface-variant);
}

.char-count {
font-size: var(--font-body-sm);
color: var(--on-surface-variant);
font-family: var(--font-mono);
}

button:focus-visible,
.btn:focus-visible,
a:focus-visible {
Expand Down
7 changes: 5 additions & 2 deletions frontend/static/css/profile.css
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,12 @@

.profile-actions {
flex-direction: column;
align-items: stretch;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
.profile-actions .btn-secondary,
.profile-actions .btn-danger {
.profile-actions .btn {
width: 100%;
}
}
Expand Down
32 changes: 32 additions & 0 deletions frontend/static/scripts/confirm-dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,36 @@
if (e.key === 'Escape') close();
});
});

const BAN_TRIGGER_SELECTOR = '.ban-trigger';

document.addEventListener('click', (e) => {
const trigger = e.target.closest(BAN_TRIGGER_SELECTOR);
if (!trigger) return;

const userId = trigger.getAttribute('data-user-id');
const username = trigger.getAttribute('data-username');
const modal = document.getElementById('ban-modal');
if (!modal) return;

const form = document.getElementById('ban-form');
form.action = '/admin/users/' + userId + '/ban';

document.getElementById('ban-username').textContent = username;
document.getElementById('ban-reason-input').value = '';
document.getElementById('ban-reason-count').textContent = '0';
modal.showModal();
});

document.addEventListener('click', (e) => {
if (e.target.id === 'ban-modal-cancel') {
document.getElementById('ban-modal')?.close();
}
});

document.addEventListener('input', (e) => {
if (e.target.id === 'ban-reason-input') {
document.getElementById('ban-reason-count').textContent = e.target.value.length;
}
});
})();
25 changes: 25 additions & 0 deletions frontend/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,31 @@ <h2 class="modal-title">Confirm</h2>
</div>
</dialog>

<dialog id="ban-modal" class="dialog">
<div class="modal-header">
<h2 class="modal-title">Ban <span id="ban-username"></span></h2>
</div>
<form method="POST" id="ban-form">
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-input-group">
<label for="ban-reason-input">Reason (optional)</label>
<div class="hint-row">
<span class="hint-text">Maximum 150 characters</span>
<span class="char-count"><span id="ban-reason-count">0</span> / 150</span>
</div>
<textarea id="ban-reason-input" name="ban_reason"
rows="3" placeholder="e.g., Spam, harassment..."
class="ban-reason-textarea" maxlength="150"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-dialog btn-secondary" id="ban-modal-cancel" autofocus>Cancel</button>
<button type="submit" class="btn btn-dialog btn-primary">Ban</button>
</div>
</form>
</dialog>

<!-- Simple Footer -->
<footer class="footer">
<div class="main-layout">
Expand Down
12 changes: 11 additions & 1 deletion frontend/templates/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ <h1>{{ user.account_username }}</h1>
{% endif %}
<div class="info-row">
<span class="info-label">Status</span>
<span class="info-value">Active</span>
<span class="info-value">{% if user.is_banned %}Banned{% if user.ban_reason %}: {{ user.ban_reason }}{% endif %}{% else %}Active{% endif %}</span>
</div>
</div>

Expand Down Expand Up @@ -141,6 +141,16 @@ <h2 class="modal-title">Update Password</h2>

{% if current_user and current_user.account_role == 'admin' and not is_own_profile and user.account_role != 'admin' %}
<div class="profile-actions">
{% if user.is_banned %}
<form action="{{ url_for('auth.unban_account', account_id=user.account_id) }}" method="POST" class="logout-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-secondary">Unban</button>
</form>
{% else %}
<button type="button" class="btn btn-danger ban-trigger"
data-user-id="{{ user.account_id }}"
data-username="{{ user.account_username }}">Ban</button>
{% endif %}
<form id="admin-delete-account-form" action="{{ url_for('auth.delete_account') }}" method="POST" class="logout-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="account_id" value="{{ user.account_id }}">
Expand Down
14 changes: 14 additions & 0 deletions frontend/templates/user_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ <h1>Manage Users ({{ total_count }} users)</h1>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Reason</th>
<th>Member Since</th>
<th>Actions</th>
</tr>
Expand All @@ -59,6 +61,8 @@ <h1>Manage Users ({{ total_count }} users)</h1>
<td><a href="{{ url_for('auth.user_profile', username=user.account_username) }}">{{ user.account_username }}</a></td>
<td>{{ user.account_email }}</td>
<td>{{ user.account_role }}</td>
<td>{{ "Banned" if user.is_banned else "Active" }}</td>
<td>{{ user.ban_reason or '—' }}</td>
<td>{{ user.account_created_at|date_format if user.account_created_at else 'N/A' }}</td>
<td>
{% if user.account_role == 'admin' %}
Expand All @@ -73,6 +77,16 @@ <h1>Manage Users ({{ total_count }} users)</h1>
</select>
<button type="submit" class="btn btn-sm">Update</button>
</form>
{% if user.is_banned %}
<form action="{{ url_for('auth.unban_account', account_id=user.account_id) }}" method="POST" class="user-table-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-secondary">Unban</button>
</form>
{% else %}
<button type="button" class="btn btn-sm btn-danger ban-trigger"
data-user-id="{{ user.account_id }}"
data-username="{{ user.account_username }}">Ban</button>
{% endif %}
<form id="delete-user-{{ user.account_id }}" action="{{ url_for('auth.delete_account') }}" method="POST" class="user-table-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="account_id" value="{{ user.account_id }}">
Expand Down
5 changes: 5 additions & 0 deletions migrations/V13__add_ban_columns_to_accounts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE accounts
ADD COLUMN is_banned BOOLEAN NOT NULL DEFAULT FALSE;

ALTER TABLE accounts
ADD COLUMN ban_reason VARCHAR(150);
8 changes: 8 additions & 0 deletions src/application/domain/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class Account:
account_role (AccountRole): Permissions role.
account_created_at (datetime): Timestamp of account creation.
avatar_file_id (str | None): UUID of the avatar file in uploaded_files, or None.
is_banned (bool): Whether the account is currently banned.
ban_reason (str | None): Optional reason provided by admin when banning.
"""

def __init__(
Expand All @@ -35,6 +37,8 @@ def __init__(
account_role: AccountRole,
account_created_at: datetime | None,
avatar_file_id: str | None = None,
is_banned: bool = False,
ban_reason: str | None = None,
):
"""
Initialize a user account.
Expand All @@ -47,6 +51,8 @@ def __init__(
account_role (AccountRole): Permissions role.
account_created_at (datetime): Timestamp of account creation.
avatar_file_id (str | None): UUID of the avatar file in uploaded_files, or None.
is_banned (bool): Whether the account is currently banned. Defaults to False.
ban_reason (str | None): Optional reason provided by admin when banning.
"""
self.account_id = account_id
self.account_username = account_username
Expand All @@ -55,3 +61,5 @@ def __init__(
self.account_role = account_role
self.account_created_at = account_created_at
self.avatar_file_id = avatar_file_id
self.is_banned = is_banned
self.ban_reason = ban_reason
29 changes: 29 additions & 0 deletions src/application/input_ports/account_session_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,35 @@ def count_search_accounts(self, query: str) -> int:
"""
pass

@abstractmethod
def ban_account(self, admin_id: int, target_account_id: int, ban_reason: str | None) -> str | None:
"""
Bans a user account. Only admins can ban non-admin accounts.

Args:
admin_id: The unique identifier of the admin performing the action.
target_account_id: The unique identifier of the account to ban.
ban_reason: Optional reason for the ban.

Returns:
str | None: None on success, or an error message string if the operation fails.
"""
pass

@abstractmethod
def unban_account(self, admin_id: int, target_account_id: int) -> str | None:
"""
Unbans a user account. Only admins can unban accounts.

Args:
admin_id: The unique identifier of the admin performing the action.
target_account_id: The unique identifier of the account to unban.

Returns:
str | None: None on success, or an error message string if the operation fails.
"""
pass

@abstractmethod
def delete_account(self, account_id: int) -> None:
"""
Expand Down
12 changes: 12 additions & 0 deletions src/application/output_ports/account_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,18 @@ def count_search(self, query: str) -> int:
"""
pass

@abstractmethod
def update_ban_status(self, account_id: int, is_banned: bool, ban_reason: str | None) -> None:
"""
Sets or clears the ban status for a given account.

Args:
account_id: The ID of the account to update.
is_banned: True to ban, False to unban.
ban_reason: Optional reason for the ban, or None to clear.
"""
pass

@abstractmethod
def delete(self, account_id: int) -> None:
"""
Expand Down
3 changes: 3 additions & 0 deletions src/application/services/article_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ def _get_account_if_author_or_admin(self, user_id: int) -> Account | str:
# TODO: Raise InsufficientPermissionsException
return "Insufficient permissions."

if account.is_banned:
return "Account is banned."

return account

def create_article(self, title: str, content: str, author_id: int, author_role: str, description: str = "") -> Article | str:
Expand Down
2 changes: 2 additions & 0 deletions src/application/services/comment_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ def _get_account_if_exists(self, user_id: int) -> Account | str:
if not account:
# TODO: Raise AccountNotFoundException
return "Account not found."
if account.is_banned:
return "Account is banned."
return account

@staticmethod
Expand Down
Loading
Loading