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.
- {skrift-0.1.0a2 → skrift-0.1.0a4}/.gitignore +2 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/PKG-INFO +1 -1
- {skrift-0.1.0a2 → skrift-0.1.0a4}/pyproject.toml +1 -1
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/env.py +2 -1
- skrift-0.1.0a4/skrift/alembic/versions/20260129_add_oauth_accounts.py +134 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic.ini +2 -2
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/cli.py +22 -13
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/config.py +2 -2
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/controllers/auth.py +100 -32
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/models/__init__.py +2 -1
- skrift-0.1.0a4/skrift/db/models/oauth_account.py +37 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/models/user.py +5 -5
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/setup/controller.py +209 -72
- skrift-0.1.0a4/skrift/setup/state.py +315 -0
- skrift-0.1.0a4/skrift/templates/setup/configuring.html +158 -0
- skrift-0.1.0a2/skrift/setup/state.py +0 -135
- {skrift-0.1.0a2 → skrift-0.1.0a4}/README.md +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/__init__.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/__main__.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/admin/__init__.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/admin/controller.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/admin/navigation.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/script.py.mako +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/alembic/versions/20260122_200000_add_settings_table.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/asgi.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/auth/__init__.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/auth/guards.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/auth/roles.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/auth/services.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/controllers/__init__.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/controllers/web.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/__init__.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/base.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/models/page.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/models/role.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/models/setting.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/services/__init__.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/services/page_service.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/db/services/setting_service.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/lib/__init__.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/lib/exceptions.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/lib/template.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/setup/__init__.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/setup/config_writer.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/setup/middleware.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/setup/providers.py +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/static/css/style.css +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/admin.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/base.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/pages/edit.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/pages/list.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/settings/site.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/users/list.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/admin/users/roles.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/auth/dummy_login.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/auth/login.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/base.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/error-404.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/error-500.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/error.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/index.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/page.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/admin.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/auth.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/base.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/complete.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/database.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/restart.html +0 -0
- {skrift-0.1.0a2 → skrift-0.1.0a4}/skrift/templates/setup/site.html +0 -0
|
@@ -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.
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
#
|
|
13
|
-
_env_file = Path(
|
|
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
|
-
|
|
320
|
+
email = user_data["email"]
|
|
321
|
+
|
|
322
|
+
# Step 1: Check if OAuth account already exists
|
|
319
323
|
result = await db_session.execute(
|
|
320
|
-
select(
|
|
324
|
+
select(OAuthAccount)
|
|
325
|
+
.options(selectinload(OAuthAccount.user))
|
|
326
|
+
.where(OAuthAccount.provider == provider, OAuthAccount.provider_account_id == oauth_id)
|
|
321
327
|
)
|
|
322
|
-
|
|
328
|
+
oauth_account = result.scalar_one_or_none()
|
|
323
329
|
|
|
324
|
-
if
|
|
325
|
-
#
|
|
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
|
-
#
|
|
332
|
-
user =
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
#
|
|
447
|
+
# Step 1: Check if OAuth account already exists
|
|
409
448
|
result = await db_session.execute(
|
|
410
|
-
select(
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
456
|
+
oauth_account = result.scalar_one_or_none()
|
|
416
457
|
|
|
417
|
-
if
|
|
418
|
-
#
|
|
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
|
-
#
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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=
|
|
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"
|