"""the full auth flow: two admissions gates, emailed links, tokens, passkeys
Adds to `users` the two gates every sign-in path now checks, and the folded-nav
preference; then the four credential tables.
The backfill matters and is the only interesting thing here. Both gate columns
are nullable with no server default, and null means "shut" - so applying this to
a database with existing accounts would lock every one of them out. The
`UPDATE` below stamps them, on the reading that an account which already existed
was already vouched for by whoever created it. That is the same stance
`auth.create_user` takes with its defaults, and it is a decision rather than a
convenience: if your deployment would rather re-verify everybody, delete the
UPDATE and be ready to answer the support mail.
Revision ID: 0002
Revises: 0001
Create Date: 2026-01-02
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("email_verified_at", sa.DateTime(), nullable=True))
op.add_column("users", sa.Column("approved_at", sa.DateTime(), nullable=True))
op.add_column("users", sa.Column("nav_collapsed", sa.JSON(), nullable=True))
# Everybody who already had an account keeps it. See the note above.
op.execute(
"UPDATE users SET email_verified_at = COALESCE(created_at, now()), "
"approved_at = COALESCE(created_at, now())"
)
op.create_table(
"password_reset_tokens",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(),
nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("used_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("ix_password_reset_tokens_user_id", "password_reset_tokens",
["user_id"])
op.create_table(
"email_verification_tokens",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(),
nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("used_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("ix_email_verification_tokens_user_id",
"email_verification_tokens", ["user_id"])
op.create_table(
"api_tokens",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("org_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("prefix", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(),
nullable=False),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("revoked_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["org_id"], ["organizations.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("ix_api_tokens_user_id", "api_tokens", ["user_id"])
op.create_table(
"webauthn_credentials",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("credential_id", sa.String(length=512), nullable=False),
sa.Column("public_key", sa.Text(), nullable=False),
sa.Column("sign_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("name", sa.String(length=120), nullable=True),
sa.Column("transports", sa.String(length=120), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(),
nullable=False),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("credential_id"),
)
op.create_index("ix_webauthn_credentials_user_id", "webauthn_credentials",
["user_id"])
def downgrade() -> None:
op.drop_index("ix_webauthn_credentials_user_id", table_name="webauthn_credentials")
op.drop_table("webauthn_credentials")
op.drop_index("ix_api_tokens_user_id", table_name="api_tokens")
op.drop_table("api_tokens")
op.drop_index("ix_email_verification_tokens_user_id",
table_name="email_verification_tokens")
op.drop_table("email_verification_tokens")
op.drop_index("ix_password_reset_tokens_user_id",
table_name="password_reset_tokens")
op.drop_table("password_reset_tokens")
op.drop_column("users", "nav_collapsed")
op.drop_column("users", "approved_at")
op.drop_column("users", "email_verified_at")