Skip to content

Commit 15da3b6

Browse files
authored
Merge pull request #674 from lbedner/v0.6.12-rc1
v0.6.12-rc1
2 parents d93810e + de0b2be commit 15da3b6

11 files changed

Lines changed: 252 additions & 34 deletions

File tree

aegis/templates/copier-aegis-project/{{ project_slug }}/.env.example.jinja

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,16 @@ LOGFIRE_TOKEN=
118118
# LOGFIRE_PROJECT_URL=https://logfire.pydantic.dev/myorg/myproject
119119
{%- endif %}
120120

121-
# Authentication (set to true in production to require login)
121+
# Authentication
122+
{%- if include_auth %}
123+
# Auth service is included. Set to false to bypass login locally
124+
# (you'll be signed in as a synthetic admin dev user).
125+
AUTH_ENABLED=true
126+
{%- else %}
127+
# Auth service not included. A synthetic admin dev user is used so
128+
# handlers reading current_user don't crash. Leave as false.
122129
AUTH_ENABLED=false
130+
{%- endif %}
123131

124132
# API Keys and Secrets (add as needed)
125133
# SECRET_KEY=your-super-secret-key-here

aegis/templates/copier-aegis-project/{{ project_slug }}/app/cli/auth.py.jinja

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ from rich.table import Table
1717
import typer
1818

1919
from app.core.db import get_async_session
20+
{%- if include_auth_rbac %}
21+
from app.core.security import VALID_ROLES
22+
{%- endif %}
2023
from app.i18n import lazy_t, t
2124
from app.models.user import UserCreate
2225
from app.services.auth.user_service import UserService
@@ -255,6 +258,61 @@ async def _create_test_users(
255258
raise typer.Exit(1)
256259

257260

261+
{% if include_auth_rbac %}
262+
@app.command(help=lazy_t("auth.help_promote_user"))
263+
def promote_user(
264+
email: str = typer.Option(..., help=lazy_t("auth.opt_promote_email")),
265+
role: str = typer.Option("admin", help=lazy_t("auth.opt_promote_role")),
266+
) -> None:
267+
asyncio.run(_promote_user(email, role))
268+
269+
270+
async def _promote_user(email: str, role: str) -> None:
271+
"""Async implementation of promote_user."""
272+
if role not in VALID_ROLES:
273+
typer.secho(
274+
t("auth.invalid_role", role=role, valid=", ".join(sorted(VALID_ROLES))),
275+
fg=typer.colors.RED,
276+
)
277+
raise typer.Exit(1)
278+
279+
try:
280+
async with get_async_session() as session:
281+
user_service = UserService(session)
282+
user = await user_service.get_user_by_email(email)
283+
if not user:
284+
typer.secho(
285+
t("auth.user_not_found", email=email),
286+
fg=typer.colors.RED,
287+
)
288+
raise typer.Exit(1)
289+
290+
previous_role = user.role
291+
updated = await user_service.update_user(user.id, role=role)
292+
assert updated is not None # we just fetched it
293+
294+
typer.secho(
295+
t(
296+
"auth.user_promoted",
297+
email=updated.email,
298+
previous=previous_role,
299+
role=updated.role,
300+
),
301+
fg=typer.colors.GREEN,
302+
bold=True,
303+
)
304+
305+
except typer.Exit:
306+
raise
307+
except Exception as e:
308+
typer.secho(
309+
t("auth.promote_user_failed", error=str(e)),
310+
fg=typer.colors.RED,
311+
)
312+
raise typer.Exit(1)
313+
314+
315+
{% endif %}
258316
@app.command(help=lazy_t("auth.help_list_users"))
259317
def list_users() -> None:
260318
asyncio.run(_list_users())

aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/modals/auth_orgs_tab.py.jinja

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -164,13 +164,11 @@ class AuthOrgsTab(ft.Container):
164164

165165
self._render_orgs(self._orgs_data, org_members)
166166

167-
def _render_orgs(
168-
self,
169-
orgs: list[dict[str, Any]],
170-
org_members: dict[int, list[dict[str, Any]]],
171-
) -> None:
172-
"""Render the organizations table with expandable member rows."""
173-
refresh_row = ft.Row(
167+
def _refresh_row(self) -> ft.Row:
168+
"""Trailing-aligned refresh icon, included on render paths where a
169+
retry is meaningful (loaded and error states). Skipped on the
170+
unavailable state since refresh can't restore a missing database."""
171+
return ft.Row(
174172
[
175173
ft.Container(expand=True),
176174
ft.IconButton(
@@ -183,6 +181,13 @@ class AuthOrgsTab(ft.Container):
183181
alignment=ft.MainAxisAlignment.END,
184182
)
185183

184+
def _render_orgs(
185+
self,
186+
orgs: list[dict[str, Any]],
187+
org_members: dict[int, list[dict[str, Any]]],
188+
) -> None:
189+
"""Render the organizations table with expandable member rows."""
190+
186191
columns = [
187192
DataTableColumn("Name", style="primary"),
188193
DataTableColumn("Slug", width=120, style="secondary"),
@@ -239,14 +244,17 @@ class AuthOrgsTab(ft.Container):
239244
empty_message="No organizations found",
240245
)
241246

242-
self._content_column.controls = [refresh_row, table]
247+
self._content_column.controls = [self._refresh_row(), table]
243248
self._content_column.scroll = ft.ScrollMode.AUTO
244249
self._content_column.spacing = 0
245250
self.update()
246251

247252
def _render_error(self, message: str) -> None:
248-
"""Render an error state."""
253+
"""Render an error state. The refresh button is included so the user
254+
can retry after fixing the underlying problem (e.g. role bump)
255+
without closing and reopening the dialog."""
249256
self._content_column.controls = [
257+
self._refresh_row(),
250258
ft.Container(
251259
content=ft.Icon(
252260
ft.Icons.ERROR_OUTLINE,

aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/frontend/dashboard/modals/auth_users_tab.py.jinja

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -228,15 +228,11 @@ class AuthUsersTab(ft.Container):
228228
return user_orgs
229229

230230
{% endif %}
231-
def _render_users(
232-
self,
233-
users: list[dict[str, Any]],
234-
{% if include_auth_org %}
235-
user_orgs: dict[int, list[str]] | None = None,
236-
{% endif %}
237-
) -> None:
238-
"""Render the users table with loaded data."""
239-
refresh_row = ft.Row(
231+
def _refresh_row(self) -> ft.Row:
232+
"""Trailing-aligned refresh icon, included on render paths where a
233+
retry is meaningful (loaded and error states). Skipped on the
234+
unavailable state since refresh can't restore a missing database."""
235+
return ft.Row(
240236
[
241237
ft.Container(expand=True),
242238
ft.IconButton(
@@ -249,8 +245,16 @@ class AuthUsersTab(ft.Container):
249245
alignment=ft.MainAxisAlignment.END,
250246
)
251247

248+
def _render_users(
249+
self,
250+
users: list[dict[str, Any]],
251+
{% if include_auth_org %}
252+
user_orgs: dict[int, list[str]] | None = None,
253+
{% endif %}
254+
) -> None:
255+
"""Render the users table with loaded data."""
252256
self._content_column.controls = [
253-
refresh_row,
257+
self._refresh_row(),
254258
UsersTableSection(
255259
users,
256260
on_toggle=self._on_toggle_user,
@@ -265,8 +269,11 @@ class AuthUsersTab(ft.Container):
265269
self.update()
266270

267271
def _render_error(self, message: str) -> None:
268-
"""Render an error state."""
272+
"""Render an error state. The refresh button is included so the user
273+
can retry after fixing the underlying problem (e.g. role bump)
274+
without closing and reopening the dialog."""
269275
self._content_column.controls = [
276+
self._refresh_row(),
270277
ft.Container(
271278
content=ft.Icon(
272279
ft.Icons.ERROR_OUTLINE,

aegis/templates/copier-aegis-project/{{ project_slug }}/app/core/config.py.jinja

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ class Settings(
8080
FLET_ASSETS_DIR: str = "assets" # Directory for Flet static assets (images, etc.)
8181

8282
# Authentication settings
83-
AUTH_ENABLED: bool = False # Set to true in production to enable auth
83+
{%- if include_auth %}
84+
AUTH_ENABLED: bool = True # Auth service included; set false in .env to bypass for local dev
85+
{%- else %}
86+
AUTH_ENABLED: bool = False # Auth service not included; dev user is synthesized
87+
{%- endif %}
8488
DEV_USER_ROLE: str = "admin" # Role for synthetic dev user when AUTH_ENABLED=false
8589
INVITE_ACCEPTANCE_MODE: str = "email" # "email" requires email match, "token" allows anyone with token
8690
SECRET_KEY: str = "change-this-secret-key-in-production-use-env-variable"

aegis/templates/copier-aegis-project/{{ project_slug }}/app/i18n/locales/en.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,18 +433,25 @@
433433
"auth.users_title": "Users",
434434
"auth.created_column": "Created",
435435
"auth.list_users_failed": "Error: Failed to list users: {error}",
436+
"auth.user_not_found": "Error: No user found with email '{email}'",
437+
"auth.invalid_role": "Error: '{role}' is not a valid role. Valid roles: {valid}",
438+
"auth.user_promoted": "Updated {email}: role {previous} -> {role}",
439+
"auth.promote_user_failed": "Error: Failed to promote user: {error}",
436440
# Help text
437441
"auth.help": "Authentication management commands",
438442
"auth.help_create_test_user": "Create a test user for development and testing.",
439443
"auth.help_create_test_users": "Create multiple test users for development and testing.",
440444
"auth.help_list_users": "List all users in the system.",
445+
"auth.help_promote_user": "Change a user's role (e.g. promote to admin).",
441446
"auth.opt_email": "User email address (auto-increment: test@example.com, test1@example.com, etc.)",
442447
"auth.opt_password": "User password (generated if not provided)",
443448
"auth.opt_full_name": "User full name",
444449
"auth.opt_prefix": "Email prefix for auto-generated emails",
445450
"auth.opt_domain": "Email domain for auto-generated emails",
446451
"auth.opt_count": "Number of test users to create",
447452
"auth.opt_shared_password": "Shared password (generated if not provided)",
453+
"auth.opt_promote_email": "Email of the user to update",
454+
"auth.opt_promote_role": "New role to assign (user, moderator, admin)",
448455
# ── Tasks ────────────────────────────────────────────────────────
449456
"tasks.listing_jobs": "Listing Scheduled Jobs",
450457
"tasks.no_jobs_found": "No scheduled jobs found",

aegis/templates/copier-aegis-project/{{ project_slug }}/app/i18n/locales/zh.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,18 +433,25 @@
433433
"auth.users_title": "用户列表",
434434
"auth.created_column": "创建时间",
435435
"auth.list_users_failed": "错误:获取用户列表失败:{error}",
436+
"auth.user_not_found": "错误:未找到邮箱为「{email}」的用户",
437+
"auth.invalid_role": "错误:「{role}」不是有效角色。可选角色:{valid}",
438+
"auth.user_promoted": "已更新 {email}:角色由 {previous} 变更为 {role}",
439+
"auth.promote_user_failed": "错误:更新用户角色失败:{error}",
436440
# 帮助文本
437441
"auth.help": "认证管理命令",
438442
"auth.help_create_test_user": "创建测试用户,用于开发和测试",
439443
"auth.help_create_test_users": "批量创建测试用户,用于开发和测试",
440444
"auth.help_list_users": "列出所有用户",
445+
"auth.help_promote_user": "修改用户角色(例如提升为管理员)",
441446
"auth.opt_email": "用户邮箱(自动递增,如 test@、test1@ 等)",
442447
"auth.opt_password": "用户密码(不指定则自动生成)",
443448
"auth.opt_full_name": "用户全名",
444449
"auth.opt_prefix": "自动生成邮箱的前缀",
445450
"auth.opt_domain": "自动生成邮箱的域名",
446451
"auth.opt_count": "创建数量",
447452
"auth.opt_shared_password": "共享密码(不指定则自动生成)",
453+
"auth.opt_promote_email": "要更新的用户邮箱",
454+
"auth.opt_promote_role": "要分配的新角色(user、moderator、admin)",
448455
# ── 定时任务 ──────────────────────────────────────────────────────
449456
"tasks.listing_jobs": "已调度任务列表",
450457
"tasks.no_jobs_found": "没有已调度的任务",

aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/auth/user_service.py.jinja

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import secrets
44
from datetime import UTC, datetime, timedelta
55

6+
{% if include_auth_rbac %}
7+
from sqlalchemy import func
8+
{% endif %}
69
{% if include_auth_org %}
710
from sqlmodel import delete, select
811
{% else %}
@@ -39,16 +42,32 @@ class UserService:
3942
self.db = db
4043

4144
async def create_user(self, user_data: UserCreate) -> User:
42-
"""Create a new user asynchronously."""
45+
"""Create a new user asynchronously.
46+
{%- if include_auth_rbac %}
47+
48+
The very first user in the system is auto-promoted to ``admin`` so
49+
the operator who runs ``aegis init`` and registers can reach the
50+
admin-gated tabs (Users, Orgs) without a manual SQL update.
51+
{%- endif %}
52+
"""
4353
# Hash the password
4454
hashed_password = get_password_hash(user_data.password)
4555

56+
{%- if include_auth_rbac %}
57+
# First user becomes admin
58+
count_result = await self.db.exec(select(func.count()).select_from(User))
59+
is_first_user = count_result.one() == 0
60+
61+
{%- endif %}
4662
# Create user object
4763
user = User(
4864
email=user_data.email.lower(),
4965
full_name=user_data.full_name,
5066
hashed_password=hashed_password,
5167
is_active=user_data.is_active,
68+
{%- if include_auth_rbac %}
69+
role="admin" if is_first_user else "user",
70+
{%- endif %}
5271
created_at=datetime.now(UTC).replace(tzinfo=None),
5372
)
5473

@@ -322,6 +341,11 @@ class UserService:
322341
# Path 3: brand-new user.
323342
if not settings.REGISTRATION_ENABLED:
324343
raise RegistrationClosed()
344+
{%- if include_auth_rbac %}
345+
# First user becomes admin (matches password-registration behavior).
346+
count_result = await self.db.exec(select(func.count()).select_from(User))
347+
is_first_user = count_result.one() == 0
348+
{%- endif %}
325349
user = User(
326350
email=email,
327351
full_name=provider_username or email.split("@")[0],
@@ -330,6 +354,9 @@ class UserService:
330354
hashed_password="",
331355
is_active=True,
332356
is_verified=True, # provider guarantees email ownership
357+
{%- if include_auth_rbac %}
358+
role="admin" if is_first_user else "user",
359+
{%- endif %}
333360
)
334361
self.db.add(user)
335362
await self.db.flush() # need user.id for the identity row

0 commit comments

Comments
 (0)