From b18f69ea62e23a0f2628b8ad23e5b02d1d3b17b3 Mon Sep 17 00:00:00 2001 From: arrelin Date: Fri, 23 Jan 2026 12:51:34 +0300 Subject: [PATCH] personal account --- backend/src/lib.rs | 9 +- backend/src/routes/mod.rs | 1 + backend/src/routes/user.rs | 97 +++++++ backend/src/services/family_service.rs | 8 + backend/src/services/mod.rs | 2 + backend/src/services/user_service.rs | 44 +++ frontend/src/App.tsx | 13 +- frontend/src/api/client.ts | 10 + frontend/src/i18n/locales/en.json | 21 ++ frontend/src/i18n/locales/ru.json | 21 ++ frontend/src/index.css | 51 +++- frontend/src/pages/FamilyView.tsx | 24 +- frontend/src/pages/Profile.tsx | 354 +++++++++++++++++++++++++ frontend/src/store/useStore.ts | 26 +- frontend/src/types/index.ts | 19 ++ 15 files changed, 688 insertions(+), 12 deletions(-) create mode 100644 backend/src/routes/user.rs create mode 100644 backend/src/services/user_service.rs create mode 100644 frontend/src/pages/Profile.tsx diff --git a/backend/src/lib.rs b/backend/src/lib.rs index ada5cd0..2d6dea9 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -63,6 +63,8 @@ pub use middleware::{require_admin, require_family_access}; routes::invite_link::delete_invite_link, routes::invite_link::validate_invite_link, routes::invite_link::join_family_via_invite, + routes::user::leave_family, + routes::user::get_family_members, ), components( schemas( @@ -94,6 +96,8 @@ pub use middleware::{require_admin, require_family_access}; routes::invite_link::InviteLinkResponse, routes::invite_link::ValidateInviteResponse, routes::invite_link::JoinFamilyResponse, + routes::user::LeaveFamilyResponse, + routes::user::FamilyMember, ) ), tags( @@ -102,7 +106,8 @@ pub use middleware::{require_admin, require_family_access}; (name = "categories", description = "Category management endpoints"), (name = "expenses", description = "Expense management endpoints"), (name = "shopping-items", description = "Shopping list management endpoints"), - (name = "invite-links", description = "Family invite link management endpoints") + (name = "invite-links", description = "Family invite link management endpoints"), + (name = "user", description = "User profile management endpoints") ), info( title = "Family Budget API", @@ -154,6 +159,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result { .route("/login", post(routes::auth::login)) .route("/logout", post(routes::auth::logout)) .route("/me", get(routes::auth::me)) + .route("/me/leave-family", post(routes::user::leave_family)) .route("/my-family", post(routes::family::create_my_family)) .route("/auth/family-login", post(routes::auth::family_login)) .layer(auth_layer.clone()) @@ -193,6 +199,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result { .route("/families/:family_id/shopping-items/:id/purchased", axum::routing::patch(routes::shopping_item::mark_as_purchased)) .route("/families/:family_id/shopping-items/mark-all-purchased", post(routes::shopping_item::mark_all_as_purchased)) .route("/families/:family_id/shopping-items/clear-all", delete(routes::shopping_item::clear_all)) + .route("/families/:family_id/members", get(routes::user::get_family_members)) .route_layer(axum_middleware::from_fn(middleware::require_family_access)) .layer(session_layer.clone()) .with_state(db.clone()); diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 56ed2db..9687d54 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -5,3 +5,4 @@ pub mod auth; pub mod shopping_item; pub mod oauth; pub mod invite_link; +pub mod user; diff --git a/backend/src/routes/user.rs b/backend/src/routes/user.rs new file mode 100644 index 0000000..9b02607 --- /dev/null +++ b/backend/src/routes/user.rs @@ -0,0 +1,97 @@ +use axum::{ + extract::{State, Path}, + http::StatusCode, + Json, +}; +use axum_login::AuthSession; +use sea_orm::DatabaseConnection; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::auth::AuthBackend; +use crate::services::{UserService, FamilyService}; + +#[derive(Debug, Serialize, ToSchema)] +pub struct LeaveFamilyResponse { + pub family_deleted: bool, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct FamilyMember { + pub id: i32, + pub username: Option, + pub email: Option, + pub is_admin: bool, +} + +#[utoipa::path( + post, + path = "/me/leave-family", + tag = "user", + responses( + (status = 200, description = "Left family successfully", body = LeaveFamilyResponse), + (status = 400, description = "User is not in a family"), + (status = 401, description = "Not authenticated") + ) +)] +pub async fn leave_family( + auth_session: AuthSession, + State(db): State, +) -> Result, StatusCode> { + let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?; + + let result = UserService::leave_family(&db, user.id) + .await + .map_err(|e| { + if e.to_string().contains("not in a family") { + StatusCode::BAD_REQUEST + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + })?; + + Ok(Json(LeaveFamilyResponse { + family_deleted: result.family_deleted, + })) +} + +#[utoipa::path( + get, + path = "/families/{family_id}/members", + tag = "families", + params( + ("family_id" = i32, Path, description = "Family ID") + ), + responses( + (status = 200, description = "List of family members", body = Vec), + (status = 401, description = "Not authenticated"), + (status = 403, description = "Access denied") + ) +)] +pub async fn get_family_members( + auth_session: AuthSession, + State(db): State, + Path(family_id): Path, +) -> Result>, StatusCode> { + let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?; + + if user.family_id != Some(family_id) && !user.is_admin { + return Err(StatusCode::FORBIDDEN); + } + + let members = FamilyService::get_members(&db, family_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let response: Vec = members + .into_iter() + .map(|m| FamilyMember { + id: m.id, + username: m.username, + email: m.email, + is_admin: m.is_admin, + }) + .collect(); + + Ok(Json(response)) +} diff --git a/backend/src/services/family_service.rs b/backend/src/services/family_service.rs index 22c89d6..2476747 100644 --- a/backend/src/services/family_service.rs +++ b/backend/src/services/family_service.rs @@ -4,6 +4,7 @@ use argon2::{ Argon2, }; use crate::models::family::{self, Entity as Family, Model as FamilyModel}; +use crate::models::user::{self, Entity as User, Model as UserModel}; pub struct FamilyService; @@ -72,4 +73,11 @@ impl FamilyService { let family: family::ActiveModel = family.into(); family.delete(db).await } + + pub async fn get_members(db: &DatabaseConnection, family_id: i32) -> Result, DbErr> { + User::find() + .filter(user::Column::FamilyId.eq(family_id)) + .all(db) + .await + } } diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 6fcd018..de0256d 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -4,6 +4,7 @@ pub mod expense_service; pub mod shopping_item_service; pub mod oauth_service; pub mod invite_link_service; +pub mod user_service; pub use family_service::FamilyService; pub use category_service::CategoryService; @@ -11,3 +12,4 @@ pub use expense_service::ExpenseService; pub use shopping_item_service::ShoppingItemService; pub use oauth_service::OAuthService; pub use invite_link_service::InviteLinkService; +pub use user_service::UserService; diff --git a/backend/src/services/user_service.rs b/backend/src/services/user_service.rs new file mode 100644 index 0000000..96adeba --- /dev/null +++ b/backend/src/services/user_service.rs @@ -0,0 +1,44 @@ +use sea_orm::*; +use crate::models::user::{self, Entity as User, Model as UserModel}; +use crate::models::family::{Entity as Family}; + +pub struct UserService; + +#[derive(Debug)] +pub struct LeaveFamilyResult { + pub family_deleted: bool, +} + +impl UserService { + pub async fn leave_family(db: &DatabaseConnection, user_id: i32) -> Result { + let user = User::find_by_id(user_id) + .one(db) + .await? + .ok_or(DbErr::RecordNotFound("User not found".to_string()))?; + + let family_id = user.family_id + .ok_or(DbErr::Custom("User is not in a family".to_string()))?; + + let mut user_active: user::ActiveModel = user.into(); + user_active.family_id = Set(None); + user_active.update(db).await?; + + let remaining_members = User::find() + .filter(user::Column::FamilyId.eq(family_id)) + .count(db) + .await?; + + let family_deleted = if remaining_members == 0 { + Family::delete_by_id(family_id).exec(db).await?; + true + } else { + false + }; + + Ok(LeaveFamilyResult { family_deleted }) + } + + pub async fn find_by_id(db: &DatabaseConnection, id: i32) -> Result, DbErr> { + User::find_by_id(id).one(db).await + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9f4a031..932a513 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,16 +6,25 @@ import FamilyView from './pages/FamilyView'; import AdminPanel from './pages/AdminPanel'; import NoFamily from './pages/NoFamily'; import InvitePage from './pages/InvitePage'; +import Profile from './pages/Profile'; import { useStore } from './store/useStore'; import { authApi } from './api/client'; import { Loader2 } from 'lucide-react'; function AppContent() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { user, isAuthenticated, isLoading, setUser, setIsLoading } = useStore(); const location = useLocation(); useEffect(() => { + const storedTheme = localStorage.getItem('theme') || 'light'; + document.documentElement.setAttribute('data-theme', storedTheme); + + const storedLocale = localStorage.getItem('locale'); + if (storedLocale && storedLocale !== i18n.language) { + i18n.changeLanguage(storedLocale); + } + checkAuth(); }, []); @@ -61,6 +70,7 @@ function AppContent() { return ( } /> + } /> } /> ); @@ -70,6 +80,7 @@ function AppContent() { } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d7fb56a..ad9f5c9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -24,6 +24,8 @@ import type { InviteLinkResponse, ValidateInviteResponse, JoinFamilyResponse, + FamilyMember, + LeaveFamilyResponse, } from '../types'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; @@ -70,6 +72,14 @@ export const familyApi = { verifyPassword: (id: number, data: VerifyFamilyPasswordRequest) => apiClient.post(`/families/${id}/verify`, data), + + getMembers: (familyId: number) => + apiClient.get(`/families/${familyId}/members`), +}; + +export const userApi = { + leaveFamily: () => + apiClient.post('/me/leave-family'), }; export const categoryApi = { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 22a2766..e989cfa 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -139,5 +139,26 @@ "markButton": "Mark", "clearAll": "Clear list?", "clearAllMessage": "All items will be permanently removed from the list." + }, + "profile": { + "title": "Profile", + "info": "Information", + "username": "Username", + "email": "Email", + "family": "Family", + "familyName": "Family name", + "members": "Members", + "you": "You", + "unknownUser": "User", + "settings": "Settings", + "language": "Language", + "theme": "Theme", + "dangerZone": "Danger zone", + "leaveFamily": "Leave family", + "leaveDescription": "If you leave the family, you will lose access to the budget. If you are the last member, the family will be deleted along with all data.", + "leaveConfirm": "Are you sure you want to leave the family?", + "leaving": "Leaving...", + "leaveError": "Error leaving family", + "renameError": "Error renaming family" } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 8fad513..0410cf6 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -139,5 +139,26 @@ "markButton": "Пометить", "clearAll": "Очистить список?", "clearAllMessage": "Все покупки будут удалены из списка безвозвратно." + }, + "profile": { + "title": "Личный кабинет", + "info": "Информация", + "username": "Имя пользователя", + "email": "Email", + "family": "Семья", + "familyName": "Название семьи", + "members": "Участники", + "you": "Вы", + "unknownUser": "Пользователь", + "settings": "Настройки", + "language": "Язык", + "theme": "Тема", + "dangerZone": "Опасная зона", + "leaveFamily": "Покинуть семью", + "leaveDescription": "Если вы покинете семью, вы потеряете доступ к бюджету. Если вы последний участник, семья будет удалена вместе со всеми данными.", + "leaveConfirm": "Вы уверены, что хотите покинуть семью?", + "leaving": "Выход...", + "leaveError": "Ошибка при выходе из семьи", + "renameError": "Ошибка при переименовании семьи" } } diff --git a/frontend/src/index.css b/frontend/src/index.css index 56ffd94..d954a6c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,7 +1,56 @@ @import "tailwindcss"; +:root, +[data-theme="light"] { + --gradient-start: #667eea; + --gradient-end: #764ba2; + --glass-bg: rgba(255, 255, 255, 0.8); + --text-primary: #1f2937; + --text-secondary: #6b7280; +} + +[data-theme="dark"] { + --gradient-start: #1f2937; + --gradient-end: #111827; + --glass-bg: rgba(31, 41, 55, 0.9); + --text-primary: #f9fafb; + --text-secondary: #d1d5db; +} + +[data-theme="sunset"] { + --gradient-start: #f97316; + --gradient-end: #ec4899; + --glass-bg: rgba(255, 255, 255, 0.85); + --text-primary: #1f2937; + --text-secondary: #6b7280; +} + +[data-theme="ocean"] { + --gradient-start: #3b82f6; + --gradient-end: #06b6d4; + --glass-bg: rgba(255, 255, 255, 0.85); + --text-primary: #1f2937; + --text-secondary: #6b7280; +} + +[data-theme="forest"] { + --gradient-start: #22c55e; + --gradient-end: #14b8a6; + --glass-bg: rgba(255, 255, 255, 0.85); + --text-primary: #1f2937; + --text-secondary: #6b7280; +} + +[data-theme="purple"] { + --gradient-start: #8b5cf6; + --gradient-end: #ec4899; + --glass-bg: rgba(255, 255, 255, 0.85); + --text-primary: #1f2937; + --text-secondary: #6b7280; +} + .gradient-bg { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%); } .gradient-bg-light { diff --git a/frontend/src/pages/FamilyView.tsx b/frontend/src/pages/FamilyView.tsx index 57e7dd7..3c355b6 100644 --- a/frontend/src/pages/FamilyView.tsx +++ b/frontend/src/pages/FamilyView.tsx @@ -21,6 +21,7 @@ import { UserPlus, Copy, Check, + User, } from 'lucide-react'; import ShoppingListModal from '../components/ShoppingListModal'; @@ -267,13 +268,22 @@ export default function FamilyView() {
- +
+ + +
diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx new file mode 100644 index 0000000..9aa8ca2 --- /dev/null +++ b/frontend/src/pages/Profile.tsx @@ -0,0 +1,354 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { familyApi, userApi, authApi } from '../api/client'; +import { useStore } from '../store/useStore'; +import type { Theme } from '../types'; +import { + User as UserIcon, + Users, + Settings, + AlertTriangle, + ArrowLeft, + Loader2, + Check, + Palette, + Languages, + LogOut, + Edit3, + Save, + X, +} from 'lucide-react'; + +const THEMES: { id: Theme; name: string; gradient: string }[] = [ + { id: 'light', name: 'Light', gradient: 'bg-gradient-to-r from-gray-100 to-gray-200' }, + { id: 'dark', name: 'Dark', gradient: 'bg-gradient-to-r from-gray-800 to-gray-900' }, + { id: 'sunset', name: 'Sunset', gradient: 'bg-gradient-to-r from-orange-400 to-pink-500' }, + { id: 'ocean', name: 'Ocean', gradient: 'bg-gradient-to-r from-blue-400 to-cyan-500' }, + { id: 'forest', name: 'Forest', gradient: 'bg-gradient-to-r from-green-400 to-teal-500' }, + { id: 'purple', name: 'Purple', gradient: 'bg-gradient-to-r from-purple-500 to-pink-500' }, +]; + +export default function Profile() { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const { user, selectedFamily, setSelectedFamily, setUser, preferences, setPreferences, familyMembers, setFamilyMembers } = useStore(); + + const [membersLoading, setMembersLoading] = useState(false); + const [leavingFamily, setLeavingFamily] = useState(false); + const [editingName, setEditingName] = useState(false); + const [newFamilyName, setNewFamilyName] = useState(''); + const [savingName, setSavingName] = useState(false); + + useEffect(() => { + if (user?.family_id && selectedFamily) { + loadMembers(); + } + }, [user?.family_id, selectedFamily]); + + const loadMembers = async () => { + if (!user?.family_id) return; + try { + setMembersLoading(true); + const response = await familyApi.getMembers(user.family_id); + setFamilyMembers(response.data); + } catch (err) { + console.error('Error loading members:', err); + } finally { + setMembersLoading(false); + } + }; + + const handleLeaveFamily = async () => { + if (!confirm(t('profile.leaveConfirm'))) return; + + try { + setLeavingFamily(true); + await userApi.leaveFamily(); + + const meResponse = await authApi.me(); + setUser(meResponse.data); + setSelectedFamily(null); + setFamilyMembers([]); + + navigate('/'); + } catch (err) { + console.error('Error leaving family:', err); + alert(t('profile.leaveError')); + } finally { + setLeavingFamily(false); + } + }; + + const handleThemeChange = (theme: Theme) => { + setPreferences({ theme }); + document.documentElement.setAttribute('data-theme', theme); + }; + + const handleLocaleChange = (locale: 'ru' | 'en') => { + setPreferences({ locale }); + i18n.changeLanguage(locale); + }; + + const handleStartEditName = () => { + setNewFamilyName(selectedFamily?.name || ''); + setEditingName(true); + }; + + const handleSaveName = async () => { + if (!selectedFamily || !newFamilyName.trim()) return; + + try { + setSavingName(true); + const response = await familyApi.update(selectedFamily.id, { name: newFamilyName.trim() }); + setSelectedFamily(response.data); + setEditingName(false); + } catch (err) { + console.error('Error updating family name:', err); + alert(t('profile.renameError')); + } finally { + setSavingName(false); + } + }; + + const handleBack = () => { + if (user?.family_id) { + navigate(`/family/${user.family_id}`); + } else { + navigate('/'); + } + }; + + return ( +
+
+ + +
+
+ +
+

{t('profile.title')}

+
+ +
+
+
+
+ +
+

{t('profile.info')}

+
+
+
+ {t('profile.username')} + {user?.username || '-'} +
+
+ {t('profile.email')} + {user?.email || '-'} +
+
+
+ + {selectedFamily && ( +
+
+
+ +
+

{t('profile.family')}

+
+ +
+
+ {t('profile.familyName')} + {editingName ? ( +
+ setNewFamilyName(e.target.value)} + className="px-3 py-1 border border-gray-300 rounded-lg focus:border-purple-500 focus:ring-1 focus:ring-purple-200" + autoFocus + /> + + +
+ ) : ( +
+ {selectedFamily.name} + +
+ )} +
+
+ +
+

{t('profile.members')}

+ {membersLoading ? ( +
+ +
+ ) : ( +
+ {familyMembers.map((member) => ( +
+
+
+ {(member.username || member.email || '?')[0].toUpperCase()} +
+ + {member.username || member.email || t('profile.unknownUser')} + + {member.id === user?.id && ( + + {t('profile.you')} + + )} +
+ {member.is_admin && ( + + Admin + + )} +
+ ))} +
+ )} +
+
+ )} + +
+
+
+ +
+

{t('profile.settings')}

+
+ +
+
+
+ +

{t('profile.language')}

+
+
+ + +
+
+ +
+
+ +

{t('profile.theme')}

+
+
+ {THEMES.map((theme) => ( + + ))} +
+
+
+
+ + {selectedFamily && ( +
+
+
+ +
+

{t('profile.dangerZone')}

+
+ +

{t('profile.leaveDescription')}

+ + +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts index e244680..903cf1f 100644 --- a/frontend/src/store/useStore.ts +++ b/frontend/src/store/useStore.ts @@ -1,5 +1,11 @@ import { create } from 'zustand'; -import type { Family, Category, User } from '../types'; +import type { Family, Category, User, FamilyMember, Theme, Locale } from '../types'; + +const getStoredPreferences = () => { + const theme = (localStorage.getItem('theme') as Theme) || 'light'; + const locale = (localStorage.getItem('locale') as Locale) || 'ru'; + return { theme, locale }; +}; interface AppState { user: User | null; @@ -8,12 +14,16 @@ interface AppState { selectedFamily: Family | null; families: Family[]; categories: Category[]; + familyMembers: FamilyMember[]; + preferences: { theme: Theme; locale: Locale }; setUser: (user: User | null) => void; setIsLoading: (loading: boolean) => void; setSelectedFamily: (family: Family | null) => void; setFamilies: (families: Family[]) => void; setCategories: (categories: Category[]) => void; + setFamilyMembers: (members: FamilyMember[]) => void; + setPreferences: (prefs: Partial<{ theme: Theme; locale: Locale }>) => void; logout: () => void; } @@ -24,6 +34,8 @@ export const useStore = create((set) => ({ selectedFamily: null, families: [], categories: [], + familyMembers: [], + preferences: getStoredPreferences(), setUser: (user) => set({ user, isAuthenticated: !!user }), @@ -35,11 +47,21 @@ export const useStore = create((set) => ({ setCategories: (categories) => set({ categories }), + setFamilyMembers: (familyMembers) => set({ familyMembers }), + + setPreferences: (prefs) => set((state) => { + const newPrefs = { ...state.preferences, ...prefs }; + if (prefs.theme) localStorage.setItem('theme', prefs.theme); + if (prefs.locale) localStorage.setItem('locale', prefs.locale); + return { preferences: newPrefs }; + }), + logout: () => set({ user: null, isAuthenticated: false, selectedFamily: null, families: [], - categories: [] + categories: [], + familyMembers: [], }), })); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a2b518a..6d0c318 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -130,3 +130,22 @@ export interface JoinFamilyResponse { family_id: number; message: string; } + +export type Theme = 'light' | 'dark' | 'sunset' | 'ocean' | 'forest' | 'purple'; +export type Locale = 'ru' | 'en'; + +export interface FamilyMember { + id: number; + username: string | null; + email: string | null; + is_admin: boolean; +} + +export interface UserPreferences { + theme: Theme; + locale: Locale; +} + +export interface LeaveFamilyResponse { + family_deleted: boolean; +} -- 2.49.1