Update user model migration to set invalid password placeholder; enhance invite management with new endpoints for active invites and improved error handling in group invite creation. Refactor frontend to fetch and display active invite codes.

This commit is contained in:
mohamad 2025-05-16 22:31:44 +02:00
parent f2ac73502c
commit c2aa62fa03
6 changed files with 190 additions and 60 deletions

View File

@ -27,7 +27,7 @@ def upgrade() -> None:
op.add_column('users', sa.Column('is_verified', sa.Boolean(), nullable=True, server_default=sa.sql.expression.false()))
# 2. Set default values for existing rows
op.execute("UPDATE users SET hashed_password = '' WHERE hashed_password IS NULL")
op.execute("UPDATE users SET hashed_password = '$INVALID_PASSWORD_PLACEHOLDER$' WHERE hashed_password IS NULL")
op.execute("UPDATE users SET is_active = true WHERE is_active IS NULL")
op.execute("UPDATE users SET is_superuser = false WHERE is_superuser IS NULL")
op.execute("UPDATE users SET is_verified = false WHERE is_verified IS NULL")

View File

@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_async_session
from app.models import User
from app.auth import oauth, fastapi_users
from app.auth import oauth, fastapi_users, auth_backend
from app.config import settings
router = APIRouter()
@ -36,7 +36,7 @@ async def google_callback(request: Request, db: AsyncSession = Depends(get_async
user_to_login = new_user
# Generate JWT token
strategy = fastapi_users._auth_backends[0].get_strategy()
strategy = auth_backend.get_strategy()
token_response = await strategy.write_token(user_to_login)
access_token = token_response["access_token"]
refresh_token = token_response.get("refresh_token") # Use .get for safety, though it should be there
@ -86,7 +86,7 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_async_
user_to_login = new_user
# Generate JWT token
strategy = fastapi_users._auth_backends[0].get_strategy()
strategy = auth_backend.get_strategy()
token_response = await strategy.write_token(user_to_login)
access_token = token_response["access_token"]
refresh_token = token_response.get("refresh_token") # Use .get for safety

View File

@ -118,11 +118,57 @@ async def create_group_invite(
invite = await crud_invite.create_invite(db=db, group_id=group_id, creator_id=current_user.id)
if not invite:
logger.error(f"Failed to generate unique invite code for group {group_id}")
# This case should ideally be covered by exceptions from create_invite now
raise InviteCreationError(group_id)
logger.info(f"User {current_user.email} created invite code for group {group_id}")
try:
await db.commit() # Explicit commit before returning
logger.info(f"User {current_user.email} created and committed invite code for group {group_id}")
except Exception as e:
logger.error(f"Failed to commit transaction after creating invite for group {group_id}: {e}", exc_info=True)
await db.rollback() # Ensure rollback if explicit commit fails
# Re-raise to ensure a 500 error is returned
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save invite: {str(e)}")
return invite
@router.get(
"/{group_id}/invites",
response_model=InviteCodePublic, # Or Optional[InviteCodePublic] if it can be null
summary="Get Group Active Invite Code",
tags=["Groups", "Invites"]
)
async def get_group_active_invite(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves the active invite code for the group. Requires group membership (owner/admin to be stricter later if needed)."""
logger.info(f"User {current_user.email} attempting to get active invite for group {group_id}")
# Permission check: Ensure user is a member of the group to view invite code
# Using get_user_role_in_group which also checks membership indirectly
user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
if user_role is None: # Not a member
logger.warning(f"Permission denied: User {current_user.email} is not a member of group {group_id} and cannot view invite code.")
# More specific error or let GroupPermissionError handle if we want to be generic
raise GroupMembershipError(group_id, "view invite code for this group (not a member)")
# Fetch the active invite for the group
invite = await crud_invite.get_active_invite_for_group(db, group_id=group_id)
if not invite:
# This case means no active (non-expired, active=true) invite exists.
# The frontend can then prompt to generate one.
logger.info(f"No active invite code found for group {group_id} when requested by {current_user.email}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No active invite code found for this group. Please generate one."
)
logger.info(f"User {current_user.email} retrieved active invite code for group {group_id}")
return invite # Pydantic will convert InviteModel to InviteCodePublic
@router.delete(
"/{group_id}/leave",
response_model=Message,

View File

@ -20,68 +20,114 @@ from app.core.exceptions import (
# Invite codes should be reasonably unique, but handle potential collision
MAX_CODE_GENERATION_ATTEMPTS = 5
async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expires_in_days: int = 7) -> Optional[InviteModel]:
"""Creates a new invite code for a group."""
async def deactivate_all_active_invites_for_group(db: AsyncSession, group_id: int):
"""Deactivates all currently active invite codes for a specific group."""
try:
stmt = (
select(InviteModel)
.where(InviteModel.group_id == group_id, InviteModel.is_active == True)
)
result = await db.execute(stmt)
active_invites = result.scalars().all()
if not active_invites:
return # No active invites to deactivate
for invite in active_invites:
invite.is_active = False
db.add(invite)
# await db.flush() # Removed: Rely on caller to flush/commit
# No explicit commit here, assuming it's part of a larger transaction or caller handles commit.
except OperationalError as e:
# It's better to let the caller handle rollback or commit based on overall operation success
raise DatabaseConnectionError(f"DB connection error deactivating invites for group {group_id}: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseTransactionError(f"DB transaction error deactivating invites for group {group_id}: {str(e)}")
async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expires_in_days: int = 365 * 100) -> Optional[InviteModel]: # Default to 100 years
"""Creates a new invite code for a group, deactivating any existing active ones for that group first."""
# Deactivate existing active invites for this group
await deactivate_all_active_invites_for_group(db, group_id)
expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
potential_code = None
for attempt in range(MAX_CODE_GENERATION_ATTEMPTS):
potential_code = secrets.token_urlsafe(16)
# Check if an *active* invite with this code already exists (outside main transaction for now)
# Ideally, unique constraint on (code, is_active=true) in DB and catch IntegrityError.
# This check reduces collision chance before attempting transaction.
existing_check_stmt = select(InviteModel.id).where(InviteModel.code == potential_code, InviteModel.is_active == True).limit(1)
existing_result = await db.execute(existing_check_stmt)
if existing_result.scalar_one_or_none() is None:
break # Found a potentially unique code
break
if attempt == MAX_CODE_GENERATION_ATTEMPTS - 1:
raise InviteOperationError("Failed to generate a unique invite code after several attempts.")
# Removed explicit transaction block here, rely on FastAPI's request-level transaction.
# Final check for code collision (less critical now without explicit nested transaction rollback on collision)
# but still good to prevent duplicate active codes if possible, though the deactivate step helps.
final_check_stmt = select(InviteModel.id).where(InviteModel.code == potential_code, InviteModel.is_active == True).limit(1)
final_check_result = await db.execute(final_check_stmt)
if final_check_result.scalar_one_or_none() is not None:
# This is now more of a rare edge case if deactivate worked and code generation is diverse.
# Depending on strictness, could raise an error or just log and proceed,
# relying on the previous deactivation to ensure only one is active.
# For now, let's raise to be safe, as it implies a very quick collision.
raise InviteOperationError("Invite code collision detected just before creation attempt.")
db_invite = InviteModel(
code=potential_code,
group_id=group_id,
created_by_id=creator_id,
expires_at=expires_at,
is_active=True
)
db.add(db_invite)
await db.flush() # Flush to get ID for re-fetch and ensure it's in session before potential re-fetch.
# Re-fetch with relationships
stmt = (
select(InviteModel)
.where(InviteModel.id == db_invite.id)
.options(
selectinload(InviteModel.group),
selectinload(InviteModel.creator)
)
)
result = await db.execute(stmt)
loaded_invite = result.scalar_one_or_none()
if loaded_invite is None:
# This would be an issue, implies flush didn't work or ID was wrong.
# The main transaction will rollback if this exception is raised.
raise InviteOperationError("Failed to load invite after creation and flush.")
return loaded_invite
# No explicit commit here, FastAPI handles it for the request.
async def get_active_invite_for_group(db: AsyncSession, group_id: int) -> Optional[InviteModel]:
"""Gets the currently active and non-expired invite for a specific group."""
now = datetime.now(timezone.utc)
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
# Final check within transaction to be absolutely sure before insert
final_check_stmt = select(InviteModel.id).where(InviteModel.code == potential_code, InviteModel.is_active == True).limit(1)
final_check_result = await db.execute(final_check_stmt)
if final_check_result.scalar_one_or_none() is not None:
# Extremely unlikely if previous check passed, but handles race condition
await transaction.rollback()
raise InviteOperationError("Invite code collision detected during transaction.")
db_invite = InviteModel(
code=potential_code,
group_id=group_id,
created_by_id=creator_id,
expires_at=expires_at,
is_active=True
stmt = (
select(InviteModel).where(
InviteModel.group_id == group_id,
InviteModel.is_active == True,
InviteModel.expires_at > now # Still respect expiry, even if very long
)
db.add(db_invite)
await db.flush() # Assigns ID
# Re-fetch with relationships
stmt = (
select(InviteModel)
.where(InviteModel.id == db_invite.id)
.options(
selectinload(InviteModel.group),
selectinload(InviteModel.creator)
)
.order_by(InviteModel.created_at.desc()) # Get the most recent one if multiple (should not happen)
.limit(1)
.options(
selectinload(InviteModel.group), # Eager load group
selectinload(InviteModel.creator) # Eager load creator
)
result = await db.execute(stmt)
loaded_invite = result.scalar_one_or_none()
if loaded_invite is None:
await transaction.rollback()
raise InviteOperationError("Failed to load invite after creation.")
await transaction.commit()
return loaded_invite
except IntegrityError as e: # Catch if DB unique constraint on code is violated
# Rollback handled by context manager
raise DatabaseIntegrityError(f"Failed to create invite due to DB integrity: {str(e)}")
)
result = await db.execute(stmt)
return result.scalars().first()
except OperationalError as e:
raise DatabaseConnectionError(f"DB connection error during invite creation: {str(e)}")
raise DatabaseConnectionError(f"DB connection error fetching active invite for group {group_id}: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseTransactionError(f"DB transaction error during invite creation: {str(e)}")
raise DatabaseQueryError(f"DB query error fetching active invite for group {group_id}: {str(e)}")
async def get_active_invite_by_code(db: AsyncSession, code: str) -> Optional[InviteModel]:
"""Gets an active and non-expired invite by its code."""

View File

@ -51,6 +51,8 @@ export const API_ENDPOINTS = {
LISTS: (groupId: string) => `/groups/${groupId}/lists`,
MEMBERS: (groupId: string) => `/groups/${groupId}/members`,
MEMBER: (groupId: string, userId: string) => `/groups/${groupId}/members/${userId}`,
CREATE_INVITE: (groupId: string) => `/groups/${groupId}/invites`,
GET_ACTIVE_INVITE: (groupId: string) => `/groups/${groupId}/invites`,
LEAVE: (groupId: string) => `/groups/${groupId}/leave`,
DELETE: (groupId: string) => `/groups/${groupId}`,
SETTINGS: (groupId: string) => `/groups/${groupId}/settings`,

View File

@ -54,21 +54,24 @@
<div class="card-body">
<button class="btn btn-secondary" @click="generateInviteCode" :disabled="generatingInvite">
<span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Generate Invite Code
{{ inviteCode ? 'Regenerate Invite Code' : 'Generate Invite Code' }}
</button>
<div v-if="inviteCode" class="form-group mt-2">
<label for="inviteCodeInput" class="form-label">Invite Code:</label>
<label for="inviteCodeInput" class="form-label">Current Active Invite Code:</label>
<div class="flex items-center">
<input id="inviteCodeInput" type="text" :value="inviteCode" class="form-input flex-grow" readonly />
<button class="btn btn-neutral btn-icon-only ml-1" @click="copyInviteCodeHandler"
aria-label="Copy invite code">
<svg class="icon">
<use xlink:href="#icon-clipboard"></use>
</svg> <!-- Assuming #icon-clipboard is 'content_copy' -->
</svg>
</button>
</div>
<p v-if="copySuccess" class="form-success-text mt-1">Invite code copied to clipboard!</p>
</div>
<div v-else class="mt-2">
<p class="text-muted">No active invite code. Click the button above to generate one.</p>
</div>
</div>
</div>
@ -112,6 +115,7 @@ const group = ref<Group | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const inviteCode = ref<string | null>(null);
const inviteExpiresAt = ref<string | null>(null);
const generatingInvite = ref(false);
const copySuccess = ref(false);
const removingMember = ref<number | null>(null);
@ -123,6 +127,33 @@ const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({
source: computed(() => inviteCode.value || '')
});
const fetchActiveInviteCode = async () => {
if (!groupId.value) return;
// Consider adding a loading state for this fetch if needed, e.g., initialInviteCodeLoading
try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(groupId.value)));
if (response.data && response.data.code) {
inviteCode.value = response.data.code;
inviteExpiresAt.value = response.data.expires_at; // Store expiry
} else {
inviteCode.value = null; // No active code found
inviteExpiresAt.value = null;
}
} catch (err: any) {
if (err.response && err.response.status === 404) {
inviteCode.value = null; // Explicitly set to null on 404
inviteExpiresAt.value = null;
// Optional: notify user or set a flag to show "generate one" message more prominently
console.info('No active invite code found for this group.');
} else {
const message = err instanceof Error ? err.message : 'Failed to fetch active invite code.';
// error.value = message; // This would display a large error banner, might be too much
console.error('Error fetching active invite code:', err);
notificationStore.addNotification({ message, type: 'error' });
}
}
};
const fetchGroupDetails = async () => {
if (!groupId.value) return;
loading.value = true;
@ -138,19 +169,24 @@ const fetchGroupDetails = async () => {
} finally {
loading.value = false;
}
// Fetch active invite code after group details are loaded
await fetchActiveInviteCode();
};
const generateInviteCode = async () => {
if (!groupId.value) return;
generatingInvite.value = true;
inviteCode.value = null;
copySuccess.value = false;
try {
const response = await apiClient.post(API_ENDPOINTS.INVITES.BASE, {
group_id: groupId.value, // Ensure this matches API expectation (string or number)
});
inviteCode.value = response.data.invite_code;
notificationStore.addNotification({ message: 'Invite code generated successfully!', type: 'success' });
const response = await apiClient.post(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(groupId.value)));
if (response.data && response.data.code) {
inviteCode.value = response.data.code;
inviteExpiresAt.value = response.data.expires_at; // Update with new expiry
notificationStore.addNotification({ message: 'New invite code generated successfully!', type: 'success' });
} else {
// Should not happen if POST is successful and returns the code
throw new Error('New invite code data is invalid.');
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to generate invite code.';
console.error('Error generating invite code:', err);