skrift 0.1.0a2__tar.gz → 0.1.0a4__tar.gz

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 (73) hide show
  1. {skrift-0.1.0a2 → skrift-0.1.0a4}/.gitignore +2 -0
  2. {skrift-0.1.0a2 → skrift-0.1.0a4}/PKG-INFO +1 -1
  3. {skrift-0.1.0a2 → skrift-0.1.0a4}/pyproject.toml +1 -1
  4. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/env.py +2 -1
  5. skrift-0.1.0a4/skrift/alembic/versions/20260129_add_oauth_accounts.py +134 -0
  6. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic.ini +2 -2
  7. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/cli.py +22 -13
  8. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/config.py +2 -2
  9. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/controllers/auth.py +100 -32
  10. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/models/__init__.py +2 -1
  11. skrift-0.1.0a4/skrift/db/models/oauth_account.py +37 -0
  12. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/models/user.py +5 -5
  13. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/setup/controller.py +209 -72
  14. skrift-0.1.0a4/skrift/setup/state.py +315 -0
  15. skrift-0.1.0a4/skrift/templates/setup/configuring.html +158 -0
  16. skrift-0.1.0a2/skrift/setup/state.py +0 -135
  17. {skrift-0.1.0a2 → skrift-0.1.0a4}/README.md +0 -0
  18. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/__init__.py +0 -0
  19. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/__main__.py +0 -0
  20. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/admin/__init__.py +0 -0
  21. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/admin/controller.py +0 -0
  22. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/admin/navigation.py +0 -0
  23. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/script.py.mako +0 -0
  24. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +0 -0
  25. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +0 -0
  26. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +0 -0
  27. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +0 -0
  28. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/versions/20260122_200000_add_settings_table.py +0 -0
  29. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/asgi.py +0 -0
  30. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/auth/__init__.py +0 -0
  31. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/auth/guards.py +0 -0
  32. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/auth/roles.py +0 -0
  33. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/auth/services.py +0 -0
  34. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/controllers/__init__.py +0 -0
  35. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/controllers/web.py +0 -0
  36. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/__init__.py +0 -0
  37. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/base.py +0 -0
  38. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/models/page.py +0 -0
  39. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/models/role.py +0 -0
  40. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/models/setting.py +0 -0
  41. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/services/__init__.py +0 -0
  42. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/services/page_service.py +0 -0
  43. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/services/setting_service.py +0 -0
  44. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/lib/__init__.py +0 -0
  45. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/lib/exceptions.py +0 -0
  46. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/lib/template.py +0 -0
  47. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/setup/__init__.py +0 -0
  48. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/setup/config_writer.py +0 -0
  49. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/setup/middleware.py +0 -0
  50. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/setup/providers.py +0 -0
  51. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/static/css/style.css +0 -0
  52. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/admin.html +0 -0
  53. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/base.html +0 -0
  54. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/pages/edit.html +0 -0
  55. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/pages/list.html +0 -0
  56. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/settings/site.html +0 -0
  57. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/users/list.html +0 -0
  58. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/users/roles.html +0 -0
  59. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/auth/dummy_login.html +0 -0
  60. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/auth/login.html +0 -0
  61. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/base.html +0 -0
  62. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/error-404.html +0 -0
  63. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/error-500.html +0 -0
  64. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/error.html +0 -0
  65. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/index.html +0 -0
  66. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/page.html +0 -0
  67. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/admin.html +0 -0
  68. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/auth.html +0 -0
  69. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/base.html +0 -0
  70. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/complete.html +0 -0
  71. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/database.html +0 -0
  72. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/restart.html +0 -0
  73. {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/site.html +0 -0
@@ -20,6 +20,8 @@ wheels/
20
20
 
21
21
  # App configuration (generated by setup wizard)
22
22
  app.yaml
23
+ app.*.yaml
23
24
  app.bak.yaml
24
25
  app.yaml.backup.*
26
+ app.*.yaml.backup.*
25
27
  site/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skrift
3
- Version: 0.1.0a2
3
+ Version: 0.1.0a4
4
4
  Summary: A lightweight async Python CMS for crafting modern websites
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: advanced-alchemy>=0.26.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "skrift"
3
- version = "0.1.0a2"
3
+ version = "0.1.0a4"
4
4
  description = "A lightweight async Python CMS for crafting modern websites"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -12,6 +12,7 @@ from skrift.config import get_settings
12
12
  from skrift.db.base import Base
13
13
 
14
14
  # Import all models to ensure they're registered with Base.metadata
15
+ from skrift.db.models.oauth_account import OAuthAccount # noqa: F401
15
16
  from skrift.db.models.user import User # noqa: F401
16
17
  from skrift.db.models.page import Page # noqa: F401
17
18
  from skrift.db.models.role import Role, RolePermission # noqa: F401
@@ -31,7 +32,7 @@ def get_url() -> str:
31
32
  """Get database URL from settings or alembic.ini."""
32
33
  try:
33
34
  settings = get_settings()
34
- return settings.database_url
35
+ return settings.db.url
35
36
  except Exception:
36
37
  # Fall back to alembic.ini config if settings can't be loaded
37
38
  return config.get_main_option("sqlalchemy.url", "")
@@ -0,0 +1,134 @@
1
+ """add oauth_accounts table
2
+
3
+ Revision ID: 1a2b3c4d5e6f
4
+ Revises: 8f3a5c2d1e0b
5
+ Create Date: 2026-01-29 10:00:00.000000
6
+
7
+ This migration:
8
+ 1. Creates the oauth_accounts table to store multiple OAuth identities per user
9
+ 2. Migrates existing oauth data from users table to oauth_accounts
10
+ 3. Makes users.email nullable (for providers like Twitter that don't provide email)
11
+ 4. Removes oauth_provider and oauth_id columns from users table
12
+ """
13
+ from typing import Sequence, Union
14
+
15
+ from alembic import op
16
+ import sqlalchemy as sa
17
+ from advanced_alchemy.types import GUID, DateTimeUTC
18
+
19
+
20
+ # revision identifiers, used by Alembic.
21
+ revision: str = '1a2b3c4d5e6f'
22
+ down_revision: Union[str, None] = '8f3a5c2d1e0b'
23
+ branch_labels: Union[str, Sequence[str], None] = None
24
+ depends_on: Union[str, Sequence[str], None] = None
25
+
26
+
27
+ def upgrade() -> None:
28
+ # Step 1: Create oauth_accounts table
29
+ op.create_table(
30
+ 'oauth_accounts',
31
+ sa.Column('id', GUID(length=16), nullable=False),
32
+ sa.Column('created_at', DateTimeUTC(timezone=True), nullable=False),
33
+ sa.Column('updated_at', DateTimeUTC(timezone=True), nullable=False),
34
+ sa.Column('sa_orm_sentinel', sa.Integer(), nullable=True),
35
+ sa.Column('provider', sa.String(length=50), nullable=False),
36
+ sa.Column('provider_account_id', sa.String(length=255), nullable=False),
37
+ sa.Column('provider_email', sa.String(length=255), nullable=True),
38
+ sa.Column('user_id', GUID(length=16), nullable=False),
39
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth_accounts')),
40
+ sa.ForeignKeyConstraint(
41
+ ['user_id'], ['users.id'],
42
+ name=op.f('fk_oauth_accounts_user_id_users'),
43
+ ondelete='CASCADE'
44
+ ),
45
+ sa.UniqueConstraint(
46
+ 'provider', 'provider_account_id',
47
+ name='uq_oauth_provider_account'
48
+ ),
49
+ )
50
+ op.create_index(
51
+ op.f('ix_oauth_accounts_user_id'),
52
+ 'oauth_accounts',
53
+ ['user_id'],
54
+ unique=False
55
+ )
56
+ op.create_index(
57
+ op.f('ix_oauth_accounts_provider_account'),
58
+ 'oauth_accounts',
59
+ ['provider', 'provider_account_id'],
60
+ unique=True
61
+ )
62
+
63
+ # Step 2: Migrate existing data from users to oauth_accounts
64
+ # Generate binary UUIDs (16 bytes) for new records and copy oauth data
65
+ conn = op.get_bind()
66
+ conn.execute(sa.text("""
67
+ INSERT INTO oauth_accounts (id, created_at, updated_at, provider, provider_account_id, provider_email, user_id)
68
+ SELECT
69
+ randomblob(16),
70
+ created_at,
71
+ updated_at,
72
+ oauth_provider,
73
+ oauth_id,
74
+ email,
75
+ id
76
+ FROM users
77
+ WHERE oauth_provider IS NOT NULL AND oauth_id IS NOT NULL
78
+ """))
79
+
80
+ # Step 3: Make email nullable on users table
81
+ # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
82
+ # For SQLite, we'll use batch_alter_table
83
+ with op.batch_alter_table('users', schema=None) as batch_op:
84
+ # Drop the unique constraint on oauth_id
85
+ batch_op.drop_constraint('uq_users_oauth_id', type_='unique')
86
+ # Drop the oauth columns
87
+ batch_op.drop_column('oauth_provider')
88
+ batch_op.drop_column('oauth_id')
89
+ # Make email nullable - this requires recreating the column in SQLite
90
+ batch_op.alter_column('email',
91
+ existing_type=sa.String(length=255),
92
+ nullable=True)
93
+
94
+
95
+ def downgrade() -> None:
96
+ # Step 1: Add back oauth columns to users table
97
+ with op.batch_alter_table('users', schema=None) as batch_op:
98
+ batch_op.add_column(sa.Column('oauth_provider', sa.String(length=50), nullable=True))
99
+ batch_op.add_column(sa.Column('oauth_id', sa.String(length=255), nullable=True))
100
+ batch_op.alter_column('email',
101
+ existing_type=sa.String(length=255),
102
+ nullable=False)
103
+
104
+ # Step 2: Migrate data back from oauth_accounts to users
105
+ # Only migrate the first oauth account per user
106
+ conn = op.get_bind()
107
+ conn.execute(sa.text("""
108
+ UPDATE users
109
+ SET oauth_provider = (
110
+ SELECT provider FROM oauth_accounts
111
+ WHERE oauth_accounts.user_id = users.id
112
+ LIMIT 1
113
+ ),
114
+ oauth_id = (
115
+ SELECT provider_account_id FROM oauth_accounts
116
+ WHERE oauth_accounts.user_id = users.id
117
+ LIMIT 1
118
+ )
119
+ """))
120
+
121
+ # Step 3: Make oauth columns non-nullable and add unique constraint
122
+ with op.batch_alter_table('users', schema=None) as batch_op:
123
+ batch_op.alter_column('oauth_provider',
124
+ existing_type=sa.String(length=50),
125
+ nullable=False)
126
+ batch_op.alter_column('oauth_id',
127
+ existing_type=sa.String(length=255),
128
+ nullable=False)
129
+ batch_op.create_unique_constraint('uq_users_oauth_id', ['oauth_id'])
130
+
131
+ # Step 4: Drop oauth_accounts table
132
+ op.drop_index(op.f('ix_oauth_accounts_provider_account'), table_name='oauth_accounts')
133
+ op.drop_index(op.f('ix_oauth_accounts_user_id'), table_name='oauth_accounts')
134
+ op.drop_table('oauth_accounts')
@@ -1,8 +1,8 @@
1
1
  # Alembic Configuration File
2
2
 
3
3
  [alembic]
4
- # Path to migration scripts
5
- script_location = alembic
4
+ # Path to migration scripts (relative to this file)
5
+ script_location = %(here)s/alembic
6
6
 
7
7
  # Template used to generate migration files
8
8
  file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s
@@ -19,24 +19,33 @@ def db() -> None:
19
19
  """
20
20
  from alembic.config import main as alembic_main
21
21
 
22
- # Ensure we're running from the project root where alembic.ini is located
23
- # If alembic.ini is not in cwd, check common locations
24
- alembic_ini = Path.cwd() / "alembic.ini"
25
-
22
+ import os
23
+
24
+ # Always run from the project root (where app.yaml and .env are)
25
+ # This ensures database paths like ./app.db resolve correctly
26
+ project_root = Path.cwd()
27
+ if not (project_root / "app.yaml").exists():
28
+ # If not in project root, try parent directory
29
+ project_root = Path(__file__).parent.parent
30
+ os.chdir(project_root)
31
+
32
+ # Find alembic.ini - check project root first, then skrift package directory
33
+ alembic_ini = project_root / "alembic.ini"
26
34
  if not alembic_ini.exists():
27
- # Try to find alembic.ini relative to this module
28
- module_dir = Path(__file__).parent.parent
29
- alembic_ini = module_dir / "alembic.ini"
30
-
31
- if alembic_ini.exists():
32
- # Change to the directory containing alembic.ini
33
- import os
34
- os.chdir(module_dir)
35
- else:
35
+ skrift_dir = Path(__file__).parent
36
+ alembic_ini = skrift_dir / "alembic.ini"
37
+
38
+ if not alembic_ini.exists():
36
39
  print("Error: Could not find alembic.ini", file=sys.stderr)
37
40
  print("Make sure you're running from the project root directory.", file=sys.stderr)
38
41
  sys.exit(1)
39
42
 
43
+ # Build argv with config path at the beginning (before any subcommand)
44
+ # Original argv: ['skrift-db', 'upgrade', 'head']
45
+ # New argv: ['skrift-db', '-c', '/path/to/alembic.ini', 'upgrade', 'head']
46
+ new_argv = [sys.argv[0], "-c", str(alembic_ini)] + sys.argv[1:]
47
+ sys.argv = new_argv
48
+
40
49
  # Pass through all CLI arguments to Alembic
41
50
  sys.exit(alembic_main(sys.argv[1:]))
42
51
 
@@ -9,8 +9,8 @@ from pydantic import BaseModel
9
9
  from pydantic_settings import BaseSettings, SettingsConfigDict
10
10
 
11
11
  # Load .env file early so env vars are available for YAML interpolation
12
- # Use explicit path to handle subprocess spawning (uvicorn workers)
13
- _env_file = Path(__file__).parent.parent / ".env"
12
+ # Load from current working directory (where app.yaml lives)
13
+ _env_file = Path.cwd() / ".env"
14
14
  load_dotenv(_env_file)
15
15
 
16
16
  # Pattern to match $VAR_NAME environment variable references
@@ -18,8 +18,10 @@ from litestar.params import Parameter
18
18
  from litestar.response import Redirect, Template as TemplateResponse
19
19
  from sqlalchemy import select
20
20
  from sqlalchemy.ext.asyncio import AsyncSession
21
+ from sqlalchemy.orm import selectinload
21
22
 
22
23
  from skrift.config import get_settings
24
+ from skrift.db.models.oauth_account import OAuthAccount
23
25
  from skrift.db.models.user import User
24
26
  from skrift.setup.providers import DUMMY_PROVIDER_KEY, OAUTH_PROVIDERS, get_provider_info
25
27
 
@@ -315,30 +317,67 @@ class AuthController(Controller):
315
317
  if not oauth_id:
316
318
  raise HTTPException(status_code=400, detail="Could not determine user ID")
317
319
 
318
- # Find or create user
320
+ email = user_data["email"]
321
+
322
+ # Step 1: Check if OAuth account already exists
319
323
  result = await db_session.execute(
320
- select(User).where(User.oauth_id == oauth_id, User.oauth_provider == provider)
324
+ select(OAuthAccount)
325
+ .options(selectinload(OAuthAccount.user))
326
+ .where(OAuthAccount.provider == provider, OAuthAccount.provider_account_id == oauth_id)
321
327
  )
322
- user = result.scalar_one_or_none()
328
+ oauth_account = result.scalar_one_or_none()
323
329
 
324
- if user:
325
- # Update existing user
330
+ if oauth_account:
331
+ # Existing OAuth account - update user profile
332
+ user = oauth_account.user
326
333
  user.name = user_data["name"]
327
334
  if user_data["picture_url"]:
328
335
  user.picture_url = user_data["picture_url"]
329
336
  user.last_login_at = datetime.now(UTC)
337
+ # Update provider email if changed
338
+ if email:
339
+ oauth_account.provider_email = email
330
340
  else:
331
- # Create new user (admin role is only assigned through setup flow)
332
- user = User(
333
- oauth_provider=provider,
334
- oauth_id=oauth_id,
335
- email=user_data["email"],
336
- name=user_data["name"],
337
- picture_url=user_data["picture_url"],
338
- last_login_at=datetime.now(UTC),
339
- )
340
- db_session.add(user)
341
- await db_session.flush()
341
+ # Step 2: Check if a user with this email already exists
342
+ user = None
343
+ if email:
344
+ result = await db_session.execute(
345
+ select(User).where(User.email == email)
346
+ )
347
+ user = result.scalar_one_or_none()
348
+
349
+ if user:
350
+ # Link new OAuth account to existing user
351
+ oauth_account = OAuthAccount(
352
+ provider=provider,
353
+ provider_account_id=oauth_id,
354
+ provider_email=email,
355
+ user_id=user.id,
356
+ )
357
+ db_session.add(oauth_account)
358
+ # Update user profile
359
+ user.name = user_data["name"]
360
+ if user_data["picture_url"]:
361
+ user.picture_url = user_data["picture_url"]
362
+ user.last_login_at = datetime.now(UTC)
363
+ else:
364
+ # Step 3: Create new user + OAuth account
365
+ user = User(
366
+ email=email,
367
+ name=user_data["name"],
368
+ picture_url=user_data["picture_url"],
369
+ last_login_at=datetime.now(UTC),
370
+ )
371
+ db_session.add(user)
372
+ await db_session.flush()
373
+
374
+ oauth_account = OAuthAccount(
375
+ provider=provider,
376
+ provider_account_id=oauth_id,
377
+ provider_email=email,
378
+ user_id=user.id,
379
+ )
380
+ db_session.add(oauth_account)
342
381
 
343
382
  await db_session.commit()
344
383
 
@@ -405,31 +444,60 @@ class AuthController(Controller):
405
444
  # Generate deterministic oauth_id from email
406
445
  oauth_id = f"dummy_{hashlib.sha256(email.encode()).hexdigest()[:16]}"
407
446
 
408
- # Find or create user
447
+ # Step 1: Check if OAuth account already exists
409
448
  result = await db_session.execute(
410
- select(User).where(
411
- User.oauth_id == oauth_id,
412
- User.oauth_provider == DUMMY_PROVIDER_KEY,
449
+ select(OAuthAccount)
450
+ .options(selectinload(OAuthAccount.user))
451
+ .where(
452
+ OAuthAccount.provider == DUMMY_PROVIDER_KEY,
453
+ OAuthAccount.provider_account_id == oauth_id,
413
454
  )
414
455
  )
415
- user = result.scalar_one_or_none()
456
+ oauth_account = result.scalar_one_or_none()
416
457
 
417
- if user:
418
- # Update existing user
458
+ if oauth_account:
459
+ # Existing OAuth account - update user profile
460
+ user = oauth_account.user
419
461
  user.name = name
420
462
  user.email = email
421
463
  user.last_login_at = datetime.now(UTC)
464
+ oauth_account.provider_email = email
422
465
  else:
423
- # Create new user
424
- user = User(
425
- oauth_provider=DUMMY_PROVIDER_KEY,
426
- oauth_id=oauth_id,
427
- email=email,
428
- name=name,
429
- last_login_at=datetime.now(UTC),
466
+ # Step 2: Check if a user with this email already exists
467
+ result = await db_session.execute(
468
+ select(User).where(User.email == email)
430
469
  )
431
- db_session.add(user)
432
- await db_session.flush()
470
+ user = result.scalar_one_or_none()
471
+
472
+ if user:
473
+ # Link new OAuth account to existing user
474
+ oauth_account = OAuthAccount(
475
+ provider=DUMMY_PROVIDER_KEY,
476
+ provider_account_id=oauth_id,
477
+ provider_email=email,
478
+ user_id=user.id,
479
+ )
480
+ db_session.add(oauth_account)
481
+ # Update user profile
482
+ user.name = name
483
+ user.last_login_at = datetime.now(UTC)
484
+ else:
485
+ # Step 3: Create new user + OAuth account
486
+ user = User(
487
+ email=email,
488
+ name=name,
489
+ last_login_at=datetime.now(UTC),
490
+ )
491
+ db_session.add(user)
492
+ await db_session.flush()
493
+
494
+ oauth_account = OAuthAccount(
495
+ provider=DUMMY_PROVIDER_KEY,
496
+ provider_account_id=oauth_id,
497
+ provider_email=email,
498
+ user_id=user.id,
499
+ )
500
+ db_session.add(oauth_account)
433
501
 
434
502
  await db_session.commit()
435
503
 
@@ -1,6 +1,7 @@
1
+ from skrift.db.models.oauth_account import OAuthAccount
1
2
  from skrift.db.models.page import Page
2
3
  from skrift.db.models.role import Role, RolePermission, user_roles
3
4
  from skrift.db.models.setting import Setting
4
5
  from skrift.db.models.user import User
5
6
 
6
- __all__ = ["Page", "Role", "RolePermission", "Setting", "User", "user_roles"]
7
+ __all__ = ["OAuthAccount", "Page", "Role", "RolePermission", "Setting", "User", "user_roles"]
@@ -0,0 +1,37 @@
1
+ """OAuth account model for storing multiple OAuth identities per user."""
2
+
3
+ from typing import TYPE_CHECKING
4
+ from uuid import UUID
5
+
6
+ from sqlalchemy import ForeignKey, String, UniqueConstraint
7
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
8
+
9
+ from skrift.db.base import Base
10
+
11
+ if TYPE_CHECKING:
12
+ from skrift.db.models.user import User
13
+
14
+
15
+ class OAuthAccount(Base):
16
+ """OAuth account model linking OAuth provider identities to users.
17
+
18
+ This allows a single user to have multiple OAuth provider accounts
19
+ linked to their profile, enabling login via different providers.
20
+ """
21
+
22
+ __tablename__ = "oauth_accounts"
23
+
24
+ provider: Mapped[str] = mapped_column(String(50), nullable=False)
25
+ provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
26
+ provider_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
27
+ user_id: Mapped[UUID] = mapped_column(
28
+ ForeignKey("users.id", ondelete="CASCADE"), nullable=False
29
+ )
30
+
31
+ user: Mapped["User"] = relationship("User", back_populates="oauth_accounts")
32
+
33
+ __table_args__ = (
34
+ UniqueConstraint(
35
+ "provider", "provider_account_id", name="uq_oauth_provider_account"
36
+ ),
37
+ )
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
7
7
  from skrift.db.base import Base
8
8
 
9
9
  if TYPE_CHECKING:
10
+ from skrift.db.models.oauth_account import OAuthAccount
10
11
  from skrift.db.models.page import Page
11
12
  from skrift.db.models.role import Role
12
13
 
@@ -16,12 +17,8 @@ class User(Base):
16
17
 
17
18
  __tablename__ = "users"
18
19
 
19
- # OAuth identifiers
20
- oauth_provider: Mapped[str] = mapped_column(String(50), nullable=False)
21
- oauth_id: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
22
-
23
20
  # Profile data from OAuth provider
24
- email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
21
+ email: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
25
22
  name: Mapped[str | None] = mapped_column(String(255), nullable=True)
26
23
  picture_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
27
24
 
@@ -30,6 +27,9 @@ class User(Base):
30
27
  last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
31
28
 
32
29
  # Relationships
30
+ oauth_accounts: Mapped[list["OAuthAccount"]] = relationship(
31
+ "OAuthAccount", back_populates="user", cascade="all, delete-orphan"
32
+ )
33
33
  pages: Mapped[list["Page"]] = relationship("Page", back_populates="user")
34
34
  roles: Mapped[list["Role"]] = relationship(
35
35
  "Role", secondary="user_roles", back_populates="users", lazy="selectin"