diff --git a/backend-rs/src/routes/ratings.rs b/backend-rs/src/routes/ratings.rs index 700062b..273fafd 100644 --- a/backend-rs/src/routes/ratings.rs +++ b/backend-rs/src/routes/ratings.rs @@ -492,10 +492,7 @@ async fn discover( cond = cond.add(rating::Column::FlavorId.is_in(flavor_ids)); } - let count = Rating::find() - .filter(cond.clone()) - .count(&state.db) - .await?; + let count = Rating::find().filter(cond.clone()).count(&state.db).await?; let mut query = Rating::find().filter(cond); query = if top { query diff --git a/backend-rs/src/routes/users.rs b/backend-rs/src/routes/users.rs index 179d9a5..47b928a 100644 --- a/backend-rs/src/routes/users.rs +++ b/backend-rs/src/routes/users.rs @@ -353,7 +353,12 @@ async fn suggested( // users_by_ids orders by id; restore the activity ranking afterwards. let users = users_by_ids(&state, &ranked).await?; let mut out = build_users(&state, &ctx, users, Some(uid)).await?; - out.sort_by_key(|u| ranked.iter().position(|id| *id == u.id).unwrap_or(usize::MAX)); + out.sort_by_key(|u| { + ranked + .iter() + .position(|id| *id == u.id) + .unwrap_or(usize::MAX) + }); Ok(Json(out)) } @@ -1045,12 +1050,21 @@ async fn request_account_deletion( active.deletion_code_expires = Set(Some(expires)); active.update(&state.db).await?; + let link = format!( + "{}/settings?deletion_code={}", + state.config.frontend_url, code + ); crate::email::spawn_mail( state.config.email.clone(), "Confirm Account Deletion - Holy Flavors Archive".into(), format!( "Hi {uname},\n\nYou requested to delete your account. \ - This action is permanent.\n\nYour deletion code is: {code}" + This action is permanent and cannot be undone.\n\n\ + Your deletion code is: {code}\n\n\ + Or open this link (while signed in) to pre-fill the code, then \ + confirm the deletion on the page:\n{link}\n\n\ + If you didn't request this, you can safely ignore this email — \ + nothing will be deleted without confirmation." ), vec![email], ); @@ -1138,6 +1152,19 @@ pub(crate) async fn delete_user_cascade(state: &AppState, uid: i32) -> ApiResult "DELETE FROM api_user_following WHERE from_user_id = ?1 OR to_user_id = ?1", )) .await?; + // Legacy Django tables that still carry FKs to api_user: the DRF auth token, + // the admin action log, and the (empty but constrained) group / permission + // M2M join tables. SQLite blocks the user delete unless these go first. + txn.execute(exec("DELETE FROM authtoken_token WHERE user_id = ?")) + .await?; + txn.execute(exec("DELETE FROM django_admin_log WHERE user_id = ?")) + .await?; + txn.execute(exec("DELETE FROM api_user_groups WHERE user_id = ?")) + .await?; + txn.execute(exec( + "DELETE FROM api_user_user_permissions WHERE user_id = ?", + )) + .await?; // Token blacklist tables reference the user via outstanding tokens. txn.execute(exec( "DELETE FROM token_blacklist_blacklistedtoken WHERE token_id IN \ diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 6634fe6..69c65e7 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -28,7 +28,7 @@ import { Switch, } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../hooks/useAuth'; @@ -49,6 +49,7 @@ const readErr = (err: unknown): string | undefined => const Settings: React.FC = ({ themeName, onThemeChange }) => { const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); const { refetchUser } = useAuth(); useTitle(t('nav.settings')); @@ -78,6 +79,19 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [selectedBannerId, setSelectedBannerId] = useState(''); + // Deep link from the deletion email: ?deletion_code=NNNNNN pre-fills the code + // and opens the confirm form. The user still has to click "Delete" — the link + // is a convenience, not a one-click destruct. Strip the param after reading so + // it doesn't linger in the URL / browser history. + useEffect(() => { + const code = searchParams.get('deletion_code'); + if (!code) return; + setDeletionCode(code); + setIsDeleting(true); + searchParams.delete('deletion_code'); + setSearchParams(searchParams, { replace: true }); + }, [searchParams, setSearchParams]); + const handleGoBack = () => { if (window.history.length > 1) navigate(-1); else navigate('/');