skrift 0.1.0a12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. skrift/__init__.py +1 -0
  2. skrift/__main__.py +12 -0
  3. skrift/admin/__init__.py +11 -0
  4. skrift/admin/controller.py +452 -0
  5. skrift/admin/navigation.py +105 -0
  6. skrift/alembic/env.py +92 -0
  7. skrift/alembic/script.py.mako +26 -0
  8. skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +70 -0
  9. skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +57 -0
  10. skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +31 -0
  11. skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +43 -0
  12. skrift/alembic/versions/20260122_200000_add_settings_table.py +38 -0
  13. skrift/alembic/versions/20260129_add_oauth_accounts.py +141 -0
  14. skrift/alembic/versions/20260129_add_provider_metadata.py +29 -0
  15. skrift/alembic.ini +77 -0
  16. skrift/asgi.py +670 -0
  17. skrift/auth/__init__.py +58 -0
  18. skrift/auth/guards.py +130 -0
  19. skrift/auth/roles.py +129 -0
  20. skrift/auth/services.py +184 -0
  21. skrift/cli.py +143 -0
  22. skrift/config.py +259 -0
  23. skrift/controllers/__init__.py +4 -0
  24. skrift/controllers/auth.py +595 -0
  25. skrift/controllers/web.py +67 -0
  26. skrift/db/__init__.py +3 -0
  27. skrift/db/base.py +7 -0
  28. skrift/db/models/__init__.py +7 -0
  29. skrift/db/models/oauth_account.py +50 -0
  30. skrift/db/models/page.py +26 -0
  31. skrift/db/models/role.py +56 -0
  32. skrift/db/models/setting.py +13 -0
  33. skrift/db/models/user.py +36 -0
  34. skrift/db/services/__init__.py +1 -0
  35. skrift/db/services/oauth_service.py +195 -0
  36. skrift/db/services/page_service.py +217 -0
  37. skrift/db/services/setting_service.py +206 -0
  38. skrift/lib/__init__.py +3 -0
  39. skrift/lib/exceptions.py +168 -0
  40. skrift/lib/template.py +108 -0
  41. skrift/setup/__init__.py +14 -0
  42. skrift/setup/config_writer.py +213 -0
  43. skrift/setup/controller.py +888 -0
  44. skrift/setup/middleware.py +89 -0
  45. skrift/setup/providers.py +214 -0
  46. skrift/setup/state.py +315 -0
  47. skrift/static/css/style.css +1003 -0
  48. skrift/templates/admin/admin.html +19 -0
  49. skrift/templates/admin/base.html +24 -0
  50. skrift/templates/admin/pages/edit.html +32 -0
  51. skrift/templates/admin/pages/list.html +62 -0
  52. skrift/templates/admin/settings/site.html +32 -0
  53. skrift/templates/admin/users/list.html +58 -0
  54. skrift/templates/admin/users/roles.html +42 -0
  55. skrift/templates/auth/dummy_login.html +102 -0
  56. skrift/templates/auth/login.html +139 -0
  57. skrift/templates/base.html +52 -0
  58. skrift/templates/error-404.html +19 -0
  59. skrift/templates/error-500.html +19 -0
  60. skrift/templates/error.html +19 -0
  61. skrift/templates/index.html +9 -0
  62. skrift/templates/page.html +26 -0
  63. skrift/templates/setup/admin.html +24 -0
  64. skrift/templates/setup/auth.html +110 -0
  65. skrift/templates/setup/base.html +407 -0
  66. skrift/templates/setup/complete.html +17 -0
  67. skrift/templates/setup/configuring.html +158 -0
  68. skrift/templates/setup/database.html +125 -0
  69. skrift/templates/setup/restart.html +28 -0
  70. skrift/templates/setup/site.html +39 -0
  71. skrift-0.1.0a12.dist-info/METADATA +235 -0
  72. skrift-0.1.0a12.dist-info/RECORD +74 -0
  73. skrift-0.1.0a12.dist-info/WHEEL +4 -0
  74. skrift-0.1.0a12.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,89 @@
1
+ """Setup wizard middleware for redirecting users to the setup wizard."""
2
+
3
+ from litestar.response import Redirect
4
+ from litestar.types import ASGIApp, Receive, Scope, Send
5
+
6
+ # Paths that should be accessible during setup
7
+ SETUP_ALLOWED_PATHS = (
8
+ "/setup",
9
+ "/static",
10
+ "/auth", # Needed for OAuth callbacks during admin creation
11
+ )
12
+
13
+
14
+ class SetupMiddleware:
15
+ """ASGI middleware to redirect users to setup wizard if not configured."""
16
+
17
+ def __init__(self, app: ASGIApp, setup_complete: bool = False, use_app_state: bool = False) -> None:
18
+ """Initialize the middleware.
19
+
20
+ Args:
21
+ app: The ASGI application
22
+ setup_complete: Whether setup has been completed (static mode)
23
+ use_app_state: Whether to check app.state.setup_complete at runtime
24
+ """
25
+ self.app = app
26
+ self._setup_complete = setup_complete
27
+ self._use_app_state = use_app_state
28
+
29
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
30
+ """Process the request."""
31
+ if scope["type"] != "http":
32
+ await self.app(scope, receive, send)
33
+ return
34
+
35
+ path = scope["path"]
36
+
37
+ # Check setup complete status - either from app state (dynamic) or instance var (static)
38
+ setup_complete = self._setup_complete
39
+ if self._use_app_state and "app" in scope:
40
+ litestar_app = scope["app"]
41
+ setup_complete = getattr(litestar_app.state, "setup_complete", self._setup_complete)
42
+
43
+ # If setup is complete, block access to /setup
44
+ if setup_complete:
45
+ if path.startswith("/setup"):
46
+ # Redirect setup routes to home after setup is complete
47
+ response = Redirect(path="/")
48
+ await response(scope, receive, send)
49
+ return
50
+ await self.app(scope, receive, send)
51
+ return
52
+
53
+ # Setup not complete - check if path is allowed
54
+ if any(path.startswith(allowed) for allowed in SETUP_ALLOWED_PATHS):
55
+ await self.app(scope, receive, send)
56
+ return
57
+
58
+ # Redirect to setup wizard
59
+ response = Redirect(path="/setup")
60
+ await response(scope, receive, send)
61
+
62
+
63
+ def create_setup_middleware_factory(setup_complete: bool):
64
+ """Create a middleware factory for the setup middleware (static mode).
65
+
66
+ Args:
67
+ setup_complete: Whether setup has been completed
68
+
69
+ Returns:
70
+ Middleware factory function
71
+ """
72
+
73
+ def factory(app: ASGIApp) -> SetupMiddleware:
74
+ return SetupMiddleware(app, setup_complete=setup_complete)
75
+
76
+ return factory
77
+
78
+
79
+ def create_dynamic_setup_middleware_factory():
80
+ """Create a middleware factory that checks app state at runtime.
81
+
82
+ Returns:
83
+ Middleware factory function
84
+ """
85
+
86
+ def factory(app: ASGIApp) -> SetupMiddleware:
87
+ return SetupMiddleware(app, setup_complete=False, use_app_state=True)
88
+
89
+ return factory
@@ -0,0 +1,214 @@
1
+ """OAuth provider definitions and configuration for the setup wizard."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ DUMMY_PROVIDER_KEY = "dummy"
6
+
7
+
8
+ @dataclass
9
+ class OAuthProviderInfo:
10
+ """Information about an OAuth provider."""
11
+
12
+ name: str
13
+ auth_url: str
14
+ token_url: str
15
+ userinfo_url: str
16
+ scopes: list[str]
17
+ console_url: str
18
+ fields: list[dict]
19
+ instructions: str
20
+ icon: str = ""
21
+
22
+
23
+ OAUTH_PROVIDERS = {
24
+ "google": OAuthProviderInfo(
25
+ name="Google",
26
+ auth_url="https://accounts.google.com/o/oauth2/v2/auth",
27
+ token_url="https://oauth2.googleapis.com/token",
28
+ userinfo_url="https://www.googleapis.com/oauth2/v2/userinfo",
29
+ scopes=["openid", "email", "profile"],
30
+ console_url="https://console.cloud.google.com/apis/credentials",
31
+ fields=[
32
+ {"key": "client_id", "label": "Client ID", "type": "text"},
33
+ {"key": "client_secret", "label": "Client Secret", "type": "password"},
34
+ ],
35
+ instructions="""
36
+ 1. Go to the Google Cloud Console
37
+ 2. Create a new project or select an existing one
38
+ 3. Enable the Google+ API
39
+ 4. Go to Credentials → Create Credentials → OAuth Client ID
40
+ 5. Choose "Web application"
41
+ 6. Add the redirect URI shown below
42
+ 7. Copy the Client ID and Client Secret
43
+ """.strip(),
44
+ icon="google",
45
+ ),
46
+ "github": OAuthProviderInfo(
47
+ name="GitHub",
48
+ auth_url="https://github.com/login/oauth/authorize",
49
+ token_url="https://github.com/login/oauth/access_token",
50
+ userinfo_url="https://api.github.com/user",
51
+ scopes=["read:user", "user:email"],
52
+ console_url="https://github.com/settings/developers",
53
+ fields=[
54
+ {"key": "client_id", "label": "Client ID", "type": "text"},
55
+ {"key": "client_secret", "label": "Client Secret", "type": "password"},
56
+ ],
57
+ instructions="""
58
+ 1. Go to GitHub Settings → Developer settings → OAuth Apps
59
+ 2. Click "New OAuth App"
60
+ 3. Fill in the application details
61
+ 4. Add the redirect URI shown below as the callback URL
62
+ 5. Copy the Client ID and generate a Client Secret
63
+ """.strip(),
64
+ icon="github",
65
+ ),
66
+ "microsoft": OAuthProviderInfo(
67
+ name="Microsoft",
68
+ auth_url="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
69
+ token_url="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
70
+ userinfo_url="https://graph.microsoft.com/v1.0/me",
71
+ scopes=["openid", "email", "profile"],
72
+ console_url="https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
73
+ fields=[
74
+ {"key": "client_id", "label": "Client (Application) ID", "type": "text"},
75
+ {"key": "client_secret", "label": "Client Secret", "type": "password"},
76
+ {
77
+ "key": "tenant_id",
78
+ "label": "Tenant ID",
79
+ "type": "text",
80
+ "placeholder": "common",
81
+ "optional": True,
82
+ },
83
+ ],
84
+ instructions="""
85
+ 1. Go to Azure Portal → App registrations
86
+ 2. Click "New registration"
87
+ 3. Enter a name and select the account types you want to support
88
+ 4. Add the redirect URI shown below (Web platform)
89
+ 5. Under Certificates & secrets, create a new client secret
90
+ 6. Copy the Application (client) ID and secret value
91
+ 7. For Tenant ID: use "common" for any Microsoft account, or your specific tenant ID
92
+ """.strip(),
93
+ icon="microsoft",
94
+ ),
95
+ "discord": OAuthProviderInfo(
96
+ name="Discord",
97
+ auth_url="https://discord.com/api/oauth2/authorize",
98
+ token_url="https://discord.com/api/oauth2/token",
99
+ userinfo_url="https://discord.com/api/users/@me",
100
+ scopes=["identify", "email"],
101
+ console_url="https://discord.com/developers/applications",
102
+ fields=[
103
+ {"key": "client_id", "label": "Client ID", "type": "text"},
104
+ {"key": "client_secret", "label": "Client Secret", "type": "password"},
105
+ ],
106
+ instructions="""
107
+ 1. Go to Discord Developer Portal
108
+ 2. Create a new application
109
+ 3. Go to OAuth2 → General
110
+ 4. Add the redirect URI shown below
111
+ 5. Copy the Client ID and Client Secret
112
+ """.strip(),
113
+ icon="discord",
114
+ ),
115
+ "facebook": OAuthProviderInfo(
116
+ name="Facebook",
117
+ auth_url="https://www.facebook.com/v18.0/dialog/oauth",
118
+ token_url="https://graph.facebook.com/v18.0/oauth/access_token",
119
+ userinfo_url="https://graph.facebook.com/me?fields=id,name,email,picture",
120
+ scopes=["email", "public_profile"],
121
+ console_url="https://developers.facebook.com/apps/",
122
+ fields=[
123
+ {"key": "client_id", "label": "App ID", "type": "text"},
124
+ {"key": "client_secret", "label": "App Secret", "type": "password"},
125
+ ],
126
+ instructions="""
127
+ 1. Go to Meta for Developers
128
+ 2. Create a new app (Consumer type)
129
+ 3. Add Facebook Login product
130
+ 4. Go to Settings → Basic to find App ID and Secret
131
+ 5. Add the redirect URI shown below in Facebook Login settings
132
+ """.strip(),
133
+ icon="facebook",
134
+ ),
135
+ "twitter": OAuthProviderInfo(
136
+ name="X (Twitter)",
137
+ auth_url="https://twitter.com/i/oauth2/authorize",
138
+ token_url="https://api.twitter.com/2/oauth2/token",
139
+ userinfo_url="https://api.twitter.com/2/users/me",
140
+ scopes=["users.read", "tweet.read"],
141
+ console_url="https://developer.twitter.com/en/portal/dashboard",
142
+ fields=[
143
+ {"key": "client_id", "label": "Client ID", "type": "text"},
144
+ {"key": "client_secret", "label": "Client Secret", "type": "password"},
145
+ ],
146
+ instructions="""
147
+ 1. Go to X Developer Portal
148
+ 2. Create a new project and app
149
+ 3. Enable OAuth 2.0 in User authentication settings
150
+ 4. Add the redirect URI shown below
151
+ 5. Copy the Client ID and Client Secret (OAuth 2.0)
152
+ """.strip(),
153
+ icon="twitter",
154
+ ),
155
+ DUMMY_PROVIDER_KEY: OAuthProviderInfo(
156
+ name="Dummy (Development Only)",
157
+ auth_url="",
158
+ token_url="",
159
+ userinfo_url="",
160
+ scopes=[],
161
+ console_url="",
162
+ fields=[],
163
+ instructions="Development-only provider. DO NOT use in production.",
164
+ icon="dummy",
165
+ ),
166
+ }
167
+
168
+
169
+ def get_provider_info(provider: str) -> OAuthProviderInfo | None:
170
+ """Get provider info by key."""
171
+ return OAUTH_PROVIDERS.get(provider)
172
+
173
+
174
+ def get_all_providers() -> dict[str, OAuthProviderInfo]:
175
+ """Get all available OAuth providers (excluding dev-only providers)."""
176
+ return {k: v for k, v in OAUTH_PROVIDERS.items() if k != DUMMY_PROVIDER_KEY}
177
+
178
+
179
+ def validate_no_dummy_auth_in_production() -> None:
180
+ """Exit process if dummy auth is configured in production."""
181
+ import os
182
+ import signal
183
+ import sys
184
+
185
+ from skrift.config import get_environment, load_raw_app_config
186
+
187
+ if get_environment() != "production":
188
+ return
189
+
190
+ config = load_raw_app_config()
191
+ if config is None:
192
+ return
193
+
194
+ providers = config.get("auth", {}).get("providers", {})
195
+ if DUMMY_PROVIDER_KEY in providers:
196
+ # Only print if we haven't already (use env var as cross-process flag)
197
+ if not os.environ.get("_SKRIFT_DUMMY_ERROR_PRINTED"):
198
+ os.environ["_SKRIFT_DUMMY_ERROR_PRINTED"] = "1"
199
+ sys.stderr.write(
200
+ "\n"
201
+ "======================================================================\n"
202
+ "SECURITY ERROR: Dummy auth provider is configured in production.\n"
203
+ "Remove 'dummy' from auth.providers in app.yaml.\n"
204
+ "Server will NOT start.\n"
205
+ "======================================================================\n\n"
206
+ )
207
+ sys.stderr.flush()
208
+
209
+ # Kill parent process (uvicorn reloader) to stop respawning
210
+ try:
211
+ os.kill(os.getppid(), signal.SIGTERM)
212
+ except (ProcessLookupError, PermissionError):
213
+ pass
214
+ os._exit(1)
skrift/setup/state.py ADDED
@@ -0,0 +1,315 @@
1
+ """Setup state detection for the Skrift setup wizard.
2
+
3
+ This module implements a two-tier detection strategy:
4
+ 1. Pre-database check: Can we connect to a database?
5
+ 2. Post-database check: Is setup complete (check for setup_completed_at setting)?
6
+
7
+ Smart step detection: If config is already present, skip to the first incomplete step.
8
+ """
9
+
10
+ import os
11
+ import subprocess
12
+ from enum import Enum
13
+ from pathlib import Path
14
+ import yaml
15
+ from sqlalchemy import text
16
+ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
17
+
18
+ # Track if migrations have been run this session to avoid running multiple times
19
+ _migrations_run = False
20
+
21
+
22
+ def reset_migrations_flag() -> None:
23
+ """Reset the migrations flag to allow re-running migrations.
24
+
25
+ Call this when starting the configuring page to ensure migrations run fresh.
26
+ """
27
+ global _migrations_run
28
+ _migrations_run = False
29
+
30
+ from skrift.config import get_config_path
31
+ from skrift.db.services.setting_service import (
32
+ SETUP_COMPLETED_AT_KEY,
33
+ SITE_NAME_KEY,
34
+ get_setting,
35
+ )
36
+
37
+
38
+ class SetupStep(Enum):
39
+ """Wizard steps."""
40
+
41
+ DATABASE = "database"
42
+ AUTH = "auth"
43
+ SITE = "site"
44
+ ADMIN = "admin"
45
+ COMPLETE = "complete"
46
+
47
+
48
+ def app_yaml_exists() -> bool:
49
+ """Check if app.yaml exists in the current working directory."""
50
+ return get_config_path().exists()
51
+
52
+
53
+ def get_database_url_from_yaml() -> str | None:
54
+ """Try to get the database URL from app.yaml, returning None if not configured.
55
+
56
+ If the URL is an env var reference that isn't set, falls back to checking
57
+ for local SQLite database files.
58
+ """
59
+ import yaml
60
+
61
+ config_path = get_config_path()
62
+ if not config_path.exists():
63
+ return None
64
+
65
+ try:
66
+ with open(config_path, "r") as f:
67
+ config = yaml.safe_load(f)
68
+
69
+ if not config or "db" not in config:
70
+ return None
71
+
72
+ db_url = config["db"].get("url")
73
+ if not db_url:
74
+ return None
75
+
76
+ # If it's an env var reference, try to resolve it
77
+ if db_url.startswith("$"):
78
+ env_var = db_url[1:]
79
+ resolved = os.environ.get(env_var)
80
+ if resolved:
81
+ return resolved
82
+
83
+ # Fallback: check for local SQLite database files
84
+ for db_file in ["./app.db", "./data.db", "./skrift.db"]:
85
+ if Path(db_file).exists():
86
+ return f"sqlite+aiosqlite:///{db_file}"
87
+
88
+ return None
89
+
90
+ return db_url
91
+ except Exception:
92
+ return None
93
+
94
+
95
+ async def can_connect_to_database() -> tuple[bool, str | None]:
96
+ """Test if we can connect to the database.
97
+
98
+ Returns:
99
+ Tuple of (success, error_message)
100
+ """
101
+ db_url = get_database_url_from_yaml()
102
+ if not db_url:
103
+ return False, "Database URL not configured"
104
+
105
+ try:
106
+ engine = create_async_engine(db_url)
107
+ async with engine.connect() as conn:
108
+ await conn.execute(text("SELECT 1"))
109
+ await engine.dispose()
110
+ return True, None
111
+ except Exception as e:
112
+ return False, str(e)
113
+
114
+
115
+ async def is_setup_complete(db_session: AsyncSession) -> bool:
116
+ """Check if setup has been completed by looking for the setup_completed_at setting."""
117
+ try:
118
+ value = await get_setting(db_session, SETUP_COMPLETED_AT_KEY)
119
+ return value is not None
120
+ except Exception:
121
+ # Table might not exist yet
122
+ return False
123
+
124
+
125
+ def is_auth_configured() -> bool:
126
+ """Check if at least one OAuth provider is fully configured in app.yaml.
127
+
128
+ A provider is considered configured if it has both client_id and client_secret.
129
+
130
+ Returns:
131
+ True if at least one provider is configured, False otherwise.
132
+ """
133
+ config_path = get_config_path()
134
+ if not config_path.exists():
135
+ return False
136
+
137
+ try:
138
+ with open(config_path, "r") as f:
139
+ config = yaml.safe_load(f)
140
+
141
+ if not config:
142
+ return False
143
+
144
+ auth = config.get("auth", {})
145
+ providers = auth.get("providers", {})
146
+
147
+ for _, provider_config in providers.items():
148
+ if not isinstance(provider_config, dict):
149
+ continue
150
+ # Check if provider has both client_id and client_secret (even as env var refs)
151
+ client_id = provider_config.get("client_id", "")
152
+ client_secret = provider_config.get("client_secret", "")
153
+ if client_id and client_secret:
154
+ return True
155
+
156
+ return False
157
+ except Exception:
158
+ return False
159
+
160
+
161
+ def run_migrations_if_needed() -> tuple[bool, str | None]:
162
+ """Run database migrations if they haven't been run this session.
163
+
164
+ This ensures the database schema is up to date before checking for
165
+ settings or other database-dependent configuration.
166
+
167
+ Returns:
168
+ Tuple of (success, error_message)
169
+ """
170
+ global _migrations_run
171
+ if _migrations_run:
172
+ return True, None
173
+
174
+ try:
175
+ # Try skrift-db first
176
+ result = subprocess.run(
177
+ ["skrift-db", "upgrade", "head"],
178
+ capture_output=True,
179
+ text=True,
180
+ cwd=Path.cwd(),
181
+ timeout=60,
182
+ )
183
+ if result.returncode == 0:
184
+ _migrations_run = True
185
+ return True, None
186
+ # If skrift-db fails, try alembic directly
187
+ except (subprocess.TimeoutExpired, FileNotFoundError):
188
+ pass
189
+
190
+ try:
191
+ result = subprocess.run(
192
+ ["alembic", "upgrade", "head"],
193
+ capture_output=True,
194
+ text=True,
195
+ cwd=Path.cwd(),
196
+ timeout=60,
197
+ )
198
+ if result.returncode == 0:
199
+ _migrations_run = True
200
+ return True, None
201
+ return False, result.stderr
202
+ except subprocess.TimeoutExpired:
203
+ return False, "Migration timed out"
204
+ except FileNotFoundError:
205
+ return False, "Neither skrift-db nor alembic found"
206
+ except Exception as e:
207
+ return False, str(e)
208
+
209
+
210
+ async def is_site_configured() -> bool:
211
+ """Check if site settings have been configured in the database.
212
+
213
+ The site step is considered complete if site_name has been set.
214
+ Returns False if the settings table doesn't exist yet (pre-migration).
215
+
216
+ Returns:
217
+ True if site is configured, False otherwise.
218
+ """
219
+ db_url = get_database_url_from_yaml()
220
+ if not db_url:
221
+ return False
222
+
223
+ engine = None
224
+ try:
225
+ engine = create_async_engine(db_url)
226
+ from sqlalchemy.ext.asyncio import async_sessionmaker
227
+
228
+ async_session = async_sessionmaker(engine, expire_on_commit=False)
229
+ async with async_session() as session:
230
+ try:
231
+ site_name = await get_setting(session, SITE_NAME_KEY)
232
+ return site_name is not None
233
+ except Exception:
234
+ # Table might not exist yet (before migration)
235
+ return False
236
+ except Exception:
237
+ return False
238
+ finally:
239
+ if engine:
240
+ await engine.dispose()
241
+
242
+
243
+ async def get_first_incomplete_step() -> SetupStep:
244
+ """Determine the first incomplete step in the setup wizard.
245
+
246
+ This function checks configuration completeness for each step and returns
247
+ the first step that needs to be completed. Use this to skip already-configured
248
+ steps when the user is forced back into the setup wizard.
249
+
250
+ If database is configured and connectable, runs migrations to ensure
251
+ all tables exist before checking database-dependent configuration.
252
+
253
+ Returns:
254
+ The first setup step that needs user input.
255
+ """
256
+ # Step 1: Database - check if we can connect
257
+ if not app_yaml_exists():
258
+ return SetupStep.DATABASE
259
+
260
+ db_url = get_database_url_from_yaml()
261
+ if not db_url:
262
+ return SetupStep.DATABASE
263
+
264
+ can_connect, _ = await can_connect_to_database()
265
+ if not can_connect:
266
+ return SetupStep.DATABASE
267
+
268
+ # Database is configured and connectable - run migrations to ensure tables exist
269
+ migration_success, _ = run_migrations_if_needed()
270
+ if not migration_success:
271
+ # If migrations fail, go back to database step to show the error
272
+ return SetupStep.DATABASE
273
+
274
+ # Step 2: Auth - check if at least one provider is configured
275
+ if not is_auth_configured():
276
+ return SetupStep.AUTH
277
+
278
+ # Step 3: Site - check if site settings exist in DB
279
+ if not await is_site_configured():
280
+ return SetupStep.SITE
281
+
282
+ # Step 4: Admin - always go here if setup not complete
283
+ return SetupStep.ADMIN
284
+
285
+
286
+ async def get_setup_step(db_session: AsyncSession | None = None) -> SetupStep:
287
+ """Determine which setup step the user should be on.
288
+
289
+ Args:
290
+ db_session: Database session if available
291
+
292
+ Returns:
293
+ The appropriate setup step
294
+ """
295
+ # Pre-database check
296
+ if not app_yaml_exists():
297
+ return SetupStep.DATABASE
298
+
299
+ db_url = get_database_url_from_yaml()
300
+ if not db_url:
301
+ return SetupStep.DATABASE
302
+
303
+ can_connect, _ = await can_connect_to_database()
304
+ if not can_connect:
305
+ return SetupStep.DATABASE
306
+
307
+ # Post-database check - need a session
308
+ if db_session is None:
309
+ return SetupStep.DATABASE
310
+
311
+ if not await is_setup_complete(db_session):
312
+ # Check wizard progress stored in session (handled by controller)
313
+ return SetupStep.AUTH
314
+
315
+ return SetupStep.COMPLETE