diff --git a/backend/src/chat/chat.module.ts b/backend/src/chat/chat.module.ts index a10a1107..e8c120e5 100644 --- a/backend/src/chat/chat.module.ts +++ b/backend/src/chat/chat.module.ts @@ -9,9 +9,15 @@ import { AuthModule } from '../auth/auth.module'; import { UserService } from 'src/user/user.service'; import { PubSub } from 'graphql-subscriptions'; import { JwtCacheModule } from 'src/jwt-cache/jwt-cache.module'; +import { UploadModule } from 'src/upload/upload.module'; @Module({ - imports: [TypeOrmModule.forFeature([Chat, User]), AuthModule, JwtCacheModule], + imports: [ + TypeOrmModule.forFeature([Chat, User]), + AuthModule, + JwtCacheModule, + UploadModule, + ], providers: [ ChatResolver, ChatProxyService, diff --git a/backend/src/user/dto/upload-avatar.input.ts b/backend/src/user/dto/upload-avatar.input.ts new file mode 100644 index 00000000..1cf18ba9 --- /dev/null +++ b/backend/src/user/dto/upload-avatar.input.ts @@ -0,0 +1,8 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { FileUpload, GraphQLUpload } from 'graphql-upload-minimal'; + +@InputType() +export class UploadAvatarInput { + @Field(() => GraphQLUpload) + file: Promise; +} diff --git a/backend/src/user/user.model.ts b/backend/src/user/user.model.ts index 5cd35dfd..1655392e 100644 --- a/backend/src/user/user.model.ts +++ b/backend/src/user/user.model.ts @@ -28,6 +28,10 @@ export class User extends SystemBaseModel { @Column() password: string; + @Field({ nullable: true }) + @Column({ nullable: true }) + avatarUrl?: string; + @Field() @Column({ unique: true }) @IsEmail() diff --git a/backend/src/user/user.module.ts b/backend/src/user/user.module.ts index 9f01e50e..20f3b5c0 100644 --- a/backend/src/user/user.module.ts +++ b/backend/src/user/user.module.ts @@ -7,6 +7,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { JwtModule } from '@nestjs/jwt'; import { AuthModule } from 'src/auth/auth.module'; import { MailModule } from 'src/mail/mail.module'; +import { UploadModule } from 'src/upload/upload.module'; @Module({ imports: [ @@ -14,6 +15,7 @@ import { MailModule } from 'src/mail/mail.module'; JwtModule, AuthModule, MailModule, + UploadModule, ], providers: [UserResolver, UserService, DateScalar], exports: [UserService], diff --git a/backend/src/user/user.resolver.ts b/backend/src/user/user.resolver.ts index 37617be9..22a52ded 100644 --- a/backend/src/user/user.resolver.ts +++ b/backend/src/user/user.resolver.ts @@ -18,6 +18,7 @@ import { import { Logger } from '@nestjs/common'; import { EmailConfirmationResponse } from 'src/auth/auth.resolver'; import { ResendEmailInput } from './dto/resend-email.input'; +import { FileUpload, GraphQLUpload } from 'graphql-upload-minimal'; @ObjectType() class LoginResponse { @@ -28,6 +29,15 @@ class LoginResponse { refreshToken: string; } +@ObjectType() +class AvatarUploadResponse { + @Field() + success: boolean; + + @Field() + avatarUrl: string; +} + @Resolver(() => User) export class UserResolver { constructor( @@ -73,4 +83,41 @@ export class UserResolver { Logger.log('me id:', id); return this.userService.getUser(id); } + + /** + * Upload a new avatar for the authenticated user + * Uses validateAndBufferFile to ensure the image meets requirements + */ + @Mutation(() => AvatarUploadResponse) + async uploadAvatar( + @GetUserIdFromToken() userId: string, + @Args('file', { type: () => GraphQLUpload }) file: Promise, + ): Promise { + try { + const updatedUser = await this.userService.updateAvatar(userId, file); + return { + success: true, + avatarUrl: updatedUser.avatarUrl, + }; + } catch (error) { + // Log the error + Logger.error( + `Avatar upload failed: ${error.message}`, + error.stack, + 'UserResolver', + ); + + // Rethrow the exception to be handled by the GraphQL error handler + throw error; + } + } + + /** + * Get the avatar URL for a user + */ + @Query(() => String, { nullable: true }) + async getUserAvatar(@Args('userId') userId: string): Promise { + const user = await this.userService.getUser(userId); + return user ? user.avatarUrl : null; + } } diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index d067a058..e4476b2e 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -2,12 +2,16 @@ import { Injectable } from '@nestjs/common'; import { User } from './user.model'; import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; +import { FileUpload } from 'graphql-upload-minimal'; +import { UploadService } from '../upload/upload.service'; +import { validateAndBufferFile } from 'src/common/security/file_check'; @Injectable() export class UserService { constructor( @InjectRepository(User) private userRepository: Repository, + private readonly uploadService: UploadService, ) {} // Method to get all chats of a user @@ -25,9 +29,35 @@ export class UserService { return user; } + async getUser(id: string): Promise | null { return await this.userRepository.findOneBy({ id, }); } + + /** + * Updates the user's avatar + * @param userId User ID + * @param file File upload + * @returns Updated user object + */ + async updateAvatar(userId: string, file: Promise): Promise { + // Get the user + const user = await this.userRepository.findOneBy({ id: userId }); + if (!user) { + throw new Error('User not found'); + } + + // Validate and convert file to buffer + const uploadedFile = await file; + const { buffer, mimetype } = await validateAndBufferFile(uploadedFile); + + // Upload the validated buffer to storage + const result = await this.uploadService.upload(buffer, mimetype, 'avatars'); + + // Update the user's avatar URL + user.avatarUrl = result.url; + return this.userRepository.save(user); + } } diff --git a/codefox-common/src/common-path.ts b/codefox-common/src/common-path.ts index b81aeaa6..553dbccb 100644 --- a/codefox-common/src/common-path.ts +++ b/codefox-common/src/common-path.ts @@ -44,6 +44,14 @@ export const getModelStatusPath = (): string => { return modelStatusPath; }; +//Media Directory +export const getMediaDir = (): string => + ensureDir(path.join(getRootDir(), 'media')); +export const getMediaPath = (modelName: string): string => + path.join(getModelsDir(), modelName); +export const getMediaAvatarsDir = (): string => + ensureDir(path.join(getMediaDir(), 'avatars')); + // Models Directory export const getModelsDir = (): string => ensureDir(path.join(getRootDir(), 'models')); diff --git a/frontend/src/app/(main)/settings/page.tsx b/frontend/src/app/(main)/settings/page.tsx new file mode 100644 index 00000000..b64cb5e1 --- /dev/null +++ b/frontend/src/app/(main)/settings/page.tsx @@ -0,0 +1,6 @@ +import UserSetting from '@/components/settings/settings'; +import { UserSettingsBar } from '@/components/user-settings-bar'; + +export default function Page() { + return ; +} diff --git a/frontend/src/app/api/media/[...path]/route.ts b/frontend/src/app/api/media/[...path]/route.ts new file mode 100644 index 00000000..1371dc58 --- /dev/null +++ b/frontend/src/app/api/media/[...path]/route.ts @@ -0,0 +1,64 @@ +import { NextRequest } from 'next/server'; +import fs from 'fs/promises'; // Use promises API +import path from 'path'; +import { getMediaDir } from 'codefox-common'; + +export async function GET( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + try { + const mediaDir = getMediaDir(); + const filePath = path.join(mediaDir, ...params.path); + const normalizedPath = path.normalize(filePath); + + if (!normalizedPath.startsWith(mediaDir)) { + console.error('Possible directory traversal attempt:', filePath); + return new Response('Access denied', { status: 403 }); + } + + // File extension allowlist + const contentTypeMap: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + }; + + const ext = path.extname(filePath).toLowerCase(); + if (!contentTypeMap[ext]) { + return new Response('Forbidden file type', { status: 403 }); + } + + // File existence and size check + let fileStat; + try { + fileStat = await fs.stat(filePath); + } catch (err) { + return new Response('File not found', { status: 404 }); + } + + if (fileStat.size > 10 * 1024 * 1024) { + // 10MB limit + return new Response('File too large', { status: 413 }); + } + + // Read and return the file + const fileBuffer = await fs.readFile(filePath); + return new Response(fileBuffer, { + headers: { + 'Content-Type': contentTypeMap[ext], + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'public, max-age=31536000', + }, + }); + } catch (error) { + console.error('Error serving media file:', error); + const errorMessage = + process.env.NODE_ENV === 'development' + ? `Error serving file: ${error.message}` + : 'An error occurred while serving the file'; + + return new Response(errorMessage, { status: 500 }); + } +} diff --git a/frontend/src/app/api/runProject/route.ts b/frontend/src/app/api/runProject/route.ts index 2b9cac37..61d7d44a 100644 --- a/frontend/src/app/api/runProject/route.ts +++ b/frontend/src/app/api/runProject/route.ts @@ -4,6 +4,13 @@ import * as path from 'path'; import * as net from 'net'; import * as fs from 'fs'; import { getProjectPath } from 'codefox-common'; +import { useMutation } from '@apollo/client/react/hooks/useMutation'; +import { toast } from 'sonner'; +import { UPDATE_PROJECT_PHOTO_URL } from '@/graphql/request'; +import { TLS } from '@/utils/const'; +import os from 'os'; + +const isWindows = os.platform() === 'win32'; import { URL_PROTOCOL_PREFIX } from '@/utils/const'; // Persist container state to file system to recover after service restarts diff --git a/frontend/src/components/avatar-uploader.tsx b/frontend/src/components/avatar-uploader.tsx new file mode 100644 index 00000000..4fcdccf2 --- /dev/null +++ b/frontend/src/components/avatar-uploader.tsx @@ -0,0 +1,151 @@ +'use client'; + +import React, { useRef, useState } from 'react'; +import { useMutation } from '@apollo/client'; +import { UPLOAD_AVATAR } from '../graphql/request'; +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { toast } from 'sonner'; +import { useAuthContext } from '@/providers/AuthProvider'; + +// Avatar URL normalization helper +export function normalizeAvatarUrl( + avatarUrl: string | null | undefined +): string { + if (!avatarUrl) return ''; + + // Check if it's already an absolute URL (S3 case) + if (avatarUrl.startsWith('https:') || avatarUrl.startsWith('http:')) { + return avatarUrl; + } + + // Check if it's a relative media path + if (avatarUrl.startsWith('media/')) { + // Convert to API route path + return `/api/${avatarUrl}`; + } + + // Handle paths that might not have the media/ prefix + if (avatarUrl.includes('avatars/')) { + const parts = avatarUrl.split('avatars/'); + return `/api/media/avatars/${parts[parts.length - 1]}`; + } + + // Return as is for other cases + return avatarUrl; +} + +interface AvatarUploaderProps { + currentAvatarUrl: string; + avatarFallback: string; + onAvatarChange: (newUrl: string) => void; +} + +export const AvatarUploader: React.FC = ({ + currentAvatarUrl, + avatarFallback, + onAvatarChange, +}) => { + const [uploadAvatar, { loading }] = useMutation(UPLOAD_AVATAR); + const fileInputRef = useRef(null); + const [previewUrl, setPreviewUrl] = useState(null); + const { refreshUserInfo } = useAuthContext(); + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Check file size on client-side as well (5MB limit) + if (file.size > 5 * 1024 * 1024) { + toast.error('File size exceeds the maximum allowed limit (5MB)'); + return; + } + + // Check file type on client-side + const fileType = file.type; + if (!['image/jpeg', 'image/png', 'image/webp'].includes(fileType)) { + toast.error('Only JPEG, PNG, and WebP files are allowed'); + return; + } + + // Create a preview URL + const reader = new FileReader(); + reader.onload = () => { + setPreviewUrl(reader.result as string); + }; + reader.readAsDataURL(file); + + try { + const { data } = await uploadAvatar({ + variables: { file }, + context: { + // Required for file uploads with Apollo Client + headers: { + 'Apollo-Require-Preflight': 'true', + }, + }, + }); + + if (data?.uploadAvatar?.success) { + // Store the original URL from backend + const avatarUrl = data.uploadAvatar.avatarUrl; + onAvatarChange(avatarUrl); + toast.success('Avatar updated successfully'); + + // Refresh the user information in the auth context + await refreshUserInfo(); + } + } catch (error) { + console.error('Error uploading avatar:', error); + + // Extract the error message if available + let errorMessage = 'Failed to upload avatar'; + if (error.graphQLErrors && error.graphQLErrors.length > 0) { + errorMessage = error.graphQLErrors[0].message; + } + + toast.error(errorMessage); + setPreviewUrl(null); + } + }; + + const triggerFileInput = () => { + fileInputRef.current?.click(); + }; + + // Use preview URL if available, otherwise use the normalized current avatar URL + const displayUrl = previewUrl || normalizeAvatarUrl(currentAvatarUrl); + + return ( + + ); +}; diff --git a/frontend/src/components/chat/chat-panel.tsx b/frontend/src/components/chat/chat-panel.tsx index 6d713513..6753516d 100644 --- a/frontend/src/components/chat/chat-panel.tsx +++ b/frontend/src/components/chat/chat-panel.tsx @@ -36,21 +36,6 @@ export default function ChatContent({ setInput, setMessages, }: ChatProps) { - // TODO(Sma1lboy): on message edit - // onMessageEdit?: (messageId: string, newContent: string) => void; - // const [editingMessageId, setEditingMessageId] = React.useState(null); - // const [editContent, setEditContent] = React.useState(''); - // const handleEditStart = (message: Message) => { - // setEditingMessageId(message.id); - // setEditContent(message.content); - // }; - // const handleEditSubmit = (messageId: string) => { - // if (onMessageEdit) { - // onMessageEdit(messageId, editContent); - // } - // setEditingMessageId(null); - // setEditContent(''); - // }; return (
diff --git a/frontend/src/components/chat/index.tsx b/frontend/src/components/chat/index.tsx index b17f102f..6e0d717e 100644 --- a/frontend/src/components/chat/index.tsx +++ b/frontend/src/components/chat/index.tsx @@ -9,7 +9,7 @@ import { GET_CHAT_HISTORY } from '@/graphql/request'; import { useQuery } from '@apollo/client'; import { toast } from 'sonner'; import { EventEnum } from '@/const/EventEnum'; -import EditUsernameForm from '@/components/edit-username-form'; +import UserSetting from '@/components/settings/settings'; import ChatContent from '@/components/chat/chat-panel'; import { useModels } from '@/hooks/useModels'; import { useChatList } from '@/hooks/useChatList'; @@ -18,6 +18,7 @@ import { CodeEngine } from './code-engine/code-engine'; import { useProjectStatusMonitor } from '@/hooks/useProjectStatusMonitor'; import { Loader2 } from 'lucide-react'; import { useAuthContext } from '@/providers/AuthProvider'; +import { useRouter } from 'next/navigation'; export default function Chat() { // Initialize state, refs, and custom hooks @@ -30,6 +31,7 @@ export default function Chat() { const { models } = useModels(); const [selectedModel, setSelectedModel] = useState(models[0] || 'gpt-4o'); const { refetchChats } = useChatList(); + const route = useRouter(); // Project status monitoring for the current chat const { isReady, projectId, projectName, error } = @@ -93,15 +95,6 @@ export default function Chat() { }; }, [updateChatId]); - // Render the settings view if chatId indicates settings mode - if (chatId === EventEnum.SETTING) { - return ( -
- -
- ); - } - // Render the main layout return chatId ? ( Settings - + diff --git a/frontend/src/components/file-sidebar.tsx b/frontend/src/components/file-sidebar.tsx index deefbac5..79a053b1 100644 --- a/frontend/src/components/file-sidebar.tsx +++ b/frontend/src/components/file-sidebar.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; import { memo, useCallback, useEffect, useState } from 'react'; import { SquarePen } from 'lucide-react'; import SidebarSkeleton from './sidebar-skeleton'; -import UserSettings from './user-settings'; +import UserSettingsBar from './user-settings-bar'; import { SideBarItem } from './sidebar-item'; import { EventEnum } from '../const/EventEnum'; import { @@ -85,7 +85,7 @@ export default function FileSidebar({ isCollapsed, isMobile, loading }) { - + diff --git a/frontend/src/components/edit-username-form.tsx b/frontend/src/components/settings/settings.tsx similarity index 81% rename from frontend/src/components/edit-username-form.tsx rename to frontend/src/components/settings/settings.tsx index 128a06dd..68437b8e 100644 --- a/frontend/src/components/edit-username-form.tsx +++ b/frontend/src/components/settings/settings.tsx @@ -13,12 +13,13 @@ import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import React, { useEffect, useMemo, useState } from 'react'; -import { ModeToggle } from './mode-toggle'; +import { ModeToggle } from '../mode-toggle'; import { toast } from 'sonner'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import Image from 'next/image'; import { ActivityCalendar } from 'react-activity-calendar'; -import { TeamSelector } from './team-selector'; +import { TeamSelector } from '../team-selector'; +import { useQuery } from '@apollo/client'; +import { AvatarUploader } from '../avatar-uploader'; +import { useAuthContext } from '@/providers/AuthProvider'; const data = [ { @@ -54,8 +55,10 @@ const formSchema = z.object({ }), }); -export default function EditUsernameForm() { +export default function UserSetting() { const [name, setName] = useState(''); + const { user, isLoading } = useAuthContext(); + const [avatarUrl, setAvatarUrl] = useState(''); const avatarFallback = useMemo(() => { if (!name) return 'US'; @@ -63,8 +66,11 @@ export default function EditUsernameForm() { }, [name]); useEffect(() => { - setName(localStorage.getItem('ollama_user') || 'Anonymous'); - }, []); + if (user) { + setName(user.username || 'Anonymous'); + setAvatarUrl(user.avatarUrl || ''); + } + }, [user]); const form = useForm>({ resolver: zodResolver(formSchema), @@ -84,22 +90,27 @@ export default function EditUsernameForm() { form.setValue('username', e.currentTarget.value); setName(e.currentTarget.value); }; + + const handleAvatarChange = (newUrl: string) => { + setAvatarUrl(newUrl); + }; + return ( -
-

User Settings

+
+

User Settings

{/* Profile Picture Section */} -
+

Profile Picture

-
-

You look good today!

- - - {avatarFallback} - +
+
diff --git a/frontend/src/components/sidebar.tsx b/frontend/src/components/sidebar.tsx index b4f6f055..5937b1f7 100644 --- a/frontend/src/components/sidebar.tsx +++ b/frontend/src/components/sidebar.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'; import Image from 'next/image'; import { memo, useCallback, useContext, useState } from 'react'; import SidebarSkeleton from './sidebar-skeleton'; -import UserSettings from './user-settings'; +import UserSettingsBar from './user-settings-bar'; import { SideBarItem } from './sidebar-item'; import { Chat } from '@/graphql/type'; import { EventEnum } from '../const/EventEnum'; @@ -231,7 +231,7 @@ function ChatSideBarComponent({ - + { +/** + * UserSettingsBar component for managing user settings and actions. + * + * @param param0 - Props for UserSettings, including isSimple flag. + * @returns UserSettings JSX element. + */ +export const UserSettingsBar = ({ isSimple }: UserSettingsProps) => { const { user, isLoading, logout } = useAuthContext(); const [open, setOpen] = useState(false); const router = useRouter(); @@ -47,6 +55,22 @@ export const UserSettings = ({ isSimple }: UserSettingsProps) => { return user?.username || 'Anonymous'; }, [isLoading, user?.username]); + // Normalize the avatar URL + const normalizedAvatarUrl = useMemo(() => { + return normalizeAvatarUrl(user?.avatarUrl); + }, [user?.avatarUrl]); + + const handleSettingsClick = () => { + // First navigate using Next.js router + router.push('/settings'); + + // Then dispatch the event + setTimeout(() => { + const event = new Event(EventEnum.SETTING); + window.dispatchEvent(event); + }, 0); + }; + const avatarButton = useMemo(() => { return ( ); - }, [avatarFallback, displayUsername, isSimple]); + }, [ + avatarFallback, + displayUsername, + isSimple, + normalizedAvatarUrl, + user?.avatarUrl, + ]); return ( {avatarButton} - + e.preventDefault()}>
{ - window.history.replaceState({}, '', '/chat?id=setting'); - const event = new Event(EventEnum.SETTING); - window.dispatchEvent(event); - }} + onClick={handleSettingsClick} > Settings
- - Logout + +
+ + Logout +
); }; -export default memo(UserSettings); +export default memo(UserSettingsBar); diff --git a/frontend/src/graphql/request.ts b/frontend/src/graphql/request.ts index d0a4f770..e794e7dd 100644 --- a/frontend/src/graphql/request.ts +++ b/frontend/src/graphql/request.ts @@ -101,6 +101,7 @@ export const GET_USER_INFO = gql` me { username email + avatarUrl } } `; @@ -250,3 +251,20 @@ export const GET_SUBSCRIBED_PROJECTS = gql` } } `; + +// mutation to upload a user avatar +export const UPLOAD_AVATAR = gql` + mutation UploadAvatar($file: Upload!) { + uploadAvatar(file: $file) { + success + avatarUrl + } + } +`; + +//query to get user avatar +export const GET_USER_AVATAR = gql` + query GetUserAvatar($userId: String!) { + getUserAvatar(userId: $userId) + } +`; diff --git a/frontend/src/graphql/schema.gql b/frontend/src/graphql/schema.gql index 55d76b75..6c419a01 100644 --- a/frontend/src/graphql/schema.gql +++ b/frontend/src/graphql/schema.gql @@ -2,6 +2,11 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ +type AvatarUploadResponse { + avatarUrl: String! + success: Boolean! +} + type Chat { createdAt: Date! id: ID! @@ -122,6 +127,7 @@ type Mutation { updateChatTitle(updateChatTitleInput: UpdateChatTitleInput!): Chat updateProjectPhoto(input: UpdateProjectPhotoInput!): Project! updateProjectPublicStatus(isPublic: Boolean!, projectId: ID!): Project! + uploadAvatar(file: Upload!): AvatarUploadResponse! } input NewChatInput { @@ -178,6 +184,7 @@ type Query { getProject(projectId: String!): Project! getRemainingProjectLimit: Int! getSubscribedProjects: [Project!]! + getUserAvatar(userId: String!): String getUserChats: [Chat!] getUserProjects: [Project!]! isValidateProject(isValidProject: IsValidProjectInput!): Boolean! @@ -229,6 +236,7 @@ input UpdateProjectPhotoInput { scalar Upload type User { + avatarUrl: String chats: [Chat!]! createdAt: Date! email: String! diff --git a/frontend/src/graphql/type.tsx b/frontend/src/graphql/type.tsx index eaa9c3b3..3def174b 100644 --- a/frontend/src/graphql/type.tsx +++ b/frontend/src/graphql/type.tsx @@ -40,6 +40,12 @@ export type Scalars = { Upload: { input: any; output: any }; }; +export type AvatarUploadResponse = { + __typename: 'AvatarUploadResponse'; + avatarUrl: Scalars['String']['output']; + success: Scalars['Boolean']['output']; +}; + export type Chat = { __typename: 'Chat'; createdAt: Scalars['Date']['output']; @@ -166,6 +172,7 @@ export type Mutation = { updateChatTitle?: Maybe; updateProjectPhoto: Project; updateProjectPublicStatus: Project; + uploadAvatar: AvatarUploadResponse; }; export type MutationClearChatHistoryArgs = { @@ -237,6 +244,10 @@ export type MutationUpdateProjectPublicStatusArgs = { projectId: Scalars['ID']['input']; }; +export type MutationUploadAvatarArgs = { + file: Scalars['Upload']['input']; +}; + export type NewChatInput = { title?: InputMaybe; }; @@ -293,6 +304,7 @@ export type Query = { getProject: Project; getRemainingProjectLimit: Scalars['Int']['output']; getSubscribedProjects: Array; + getUserAvatar?: Maybe; getUserChats?: Maybe>; getUserProjects: Array; isValidateProject: Scalars['Boolean']['output']; @@ -320,6 +332,10 @@ export type QueryGetProjectArgs = { projectId: Scalars['String']['input']; }; +export type QueryGetUserAvatarArgs = { + userId: Scalars['String']['input']; +}; + export type QueryIsValidateProjectArgs = { isValidProject: IsValidProjectInput; }; @@ -365,6 +381,7 @@ export type UpdateProjectPhotoInput = { export type User = { __typename: 'User'; + avatarUrl?: Maybe; chats: Array; createdAt: Scalars['Date']['output']; email: Scalars['String']['output']; @@ -490,6 +507,7 @@ export type DirectiveResolverFn< /** Mapping between all available schema types and the resolvers types */ export type ResolversTypes = ResolversObject<{ + AvatarUploadResponse: ResolverTypeWrapper; Boolean: ResolverTypeWrapper; Chat: ResolverTypeWrapper; ChatCompletionChoiceType: ResolverTypeWrapper; @@ -530,6 +548,7 @@ export type ResolversTypes = ResolversObject<{ /** Mapping between all available schema types and the resolvers parents */ export type ResolversParentTypes = ResolversObject<{ + AvatarUploadResponse: AvatarUploadResponse; Boolean: Scalars['Boolean']['output']; Chat: Chat; ChatCompletionChoiceType: ChatCompletionChoiceType; @@ -566,6 +585,16 @@ export type ResolversParentTypes = ResolversObject<{ User: User; }>; +export type AvatarUploadResponseResolvers< + ContextType = any, + ParentType extends + ResolversParentTypes['AvatarUploadResponse'] = ResolversParentTypes['AvatarUploadResponse'], +> = ResolversObject<{ + avatarUrl?: Resolver; + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}>; + export type ChatResolvers< ContextType = any, ParentType extends @@ -806,6 +835,12 @@ export type MutationResolvers< 'isPublic' | 'projectId' > >; + uploadAvatar?: Resolver< + ResolversTypes['AvatarUploadResponse'], + ParentType, + ContextType, + RequireFields + >; }>; export type ProjectResolvers< @@ -922,6 +957,12 @@ export type QueryResolvers< ParentType, ContextType >; + getUserAvatar?: Resolver< + Maybe, + ParentType, + ContextType, + RequireFields + >; getUserChats?: Resolver< Maybe>, ParentType, @@ -976,6 +1017,11 @@ export type UserResolvers< ParentType extends ResolversParentTypes['User'] = ResolversParentTypes['User'], > = ResolversObject<{ + avatarUrl?: Resolver< + Maybe, + ParentType, + ContextType + >; chats?: Resolver, ParentType, ContextType>; createdAt?: Resolver; email?: Resolver; @@ -1004,6 +1050,7 @@ export type UserResolvers< }>; export type Resolvers = ResolversObject<{ + AvatarUploadResponse?: AvatarUploadResponseResolvers; Chat?: ChatResolvers; ChatCompletionChoiceType?: ChatCompletionChoiceTypeResolvers; ChatCompletionChunkType?: ChatCompletionChunkTypeResolvers; diff --git a/frontend/src/providers/AuthProvider.tsx b/frontend/src/providers/AuthProvider.tsx index f4239347..95130c43 100644 --- a/frontend/src/providers/AuthProvider.tsx +++ b/frontend/src/providers/AuthProvider.tsx @@ -23,6 +23,7 @@ interface AuthContextValue { logout: () => void; refreshAccessToken: () => Promise; validateToken: () => Promise; + refreshUserInfo: () => Promise; } const AuthContext = createContext({ @@ -34,6 +35,7 @@ const AuthContext = createContext({ logout: () => {}, refreshAccessToken: async () => {}, validateToken: async () => false, + refreshUserInfo: async () => false, }); export function AuthProvider({ children }: { children: React.ReactNode }) { @@ -82,6 +84,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, [getUserInfo]); + const refreshUserInfo = useCallback(async () => { + return await fetchUserInfo(); + }, [fetchUserInfo]); + const refreshAccessToken = useCallback(async () => { try { const refreshToken = localStorage.getItem(LocalStore.refreshToken); @@ -187,6 +193,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { logout, refreshAccessToken, validateToken, + refreshUserInfo, }} > {children}