diff --git a/package.json b/package.json index e7274ff..2ea92ed 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "frangipane-client", "private": true, - "version": "1.0.1", - "backendVersion": "1.0.4", + "version": "1.0.2", + "backendVersion": "1.0.5", "type": "module", "scripts": { "dev": "vite", diff --git a/src/api/account.ts b/src/api/account.ts index 740e52f..1f2fb10 100644 --- a/src/api/account.ts +++ b/src/api/account.ts @@ -1,50 +1,69 @@ import { UpdateUserResponse } from '../types'; -import { apiFetch } from './client' -// import { upload } from '@tauri-apps/plugin-upload'; +import { apiFetch, ApiError } from './client' import { getAuthData } from '../store'; import { API } from '../main.ts'; export function updateSettings(username: string, email: string, password: string) { - return apiFetch('/account/settings', { - method: 'PUT', - body: JSON.stringify({ username, email, password }), - }); + return apiFetch('/account/settings', { + method: 'PUT', + body: JSON.stringify({ username, email, password }), + }); } export async function uploadAvatar( - fileData: Uint8Array, - onProgress: (progress: number, total: number) => void + fileData: Uint8Array, + onProgress: (progress: number, total: number) => void ) { - const auth = await getAuthData(); - const url = `${API}/account/upload-avatar`; + const auth = await getAuthData(); + const url = `${API}/account/upload-avatar`; - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('POST', url); + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', url); - xhr.setRequestHeader('Authorization', `Bearer ${auth.token}`); - xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + xhr.setRequestHeader('Authorization', `Bearer ${auth.token}`); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); - // Handle Progress - if (xhr.upload && onProgress) { - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - onProgress(event.loaded, event.total); + if (xhr.upload && onProgress) { + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + onProgress(event.loaded, event.total); + } + }; } - }; - } - // Handle Response - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(xhr.response); - } else { - reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.responseText}`)); - } - }; + // Handle Response + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(xhr.response); + } else { + if (xhr.status === 413) { + reject(new ApiError('FILE_TOO_LARGE', 'File too large')); + return; + } - xhr.onerror = () => reject(new Error('Network error during upload')); + try { + const res = JSON.parse(xhr.responseText); + if (res && res.code && res.message) { + reject(new ApiError(res.code, res.message)); + return; + } + } catch (e) { + } - xhr.send(fileData); - }); + reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.responseText}`)); + } + }; + + xhr.onerror = () => { + if (xhr.status === 413) { + reject(new ApiError('FILE_TOO_LARGE', 'File too large')); + return; + } + + reject(new ApiError("UPLOAD_FAILED", "Failed uploading file.")); + }; + + xhr.send(fileData); + }); } diff --git a/src/api/client.ts b/src/api/client.ts index 7585653..2187a8f 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -3,47 +3,68 @@ import { getAuthData, clearAuthData } from '../store' import { API } from '../main.ts' import router from '../router' -export async function apiFetch( - path: string, - options: RequestInit = {} -): Promise { - const auth = await getAuthData() +// Custom Error class to hold the backend code +export class ApiError extends Error { + code: string; - const isFormData = options.body instanceof FormData; - - const res = await fetch(`${API}${path}`, { - ...options, - method: options.method || 'GET', - headers: { - // Only add json header if it's not formdata - ...(!isFormData ? { 'Content-Type': 'application/json' } : {}), - ...(auth.token ? { Authorization: `Bearer ${auth.token}` } : {}), - ...options.headers, - }, - }) - - if (res.status === 401 && auth.token) { - await clearAuthData() - router.push('/login') - throw new Error("Session expired") - } - - // Handle error responses - if (!res.ok) { - const text = await res.text() - throw new Error(text || res.statusText) - } - - // Get the response as text first - const responseText = await res.text() - - if (!responseText) { - return {} as T - } - - try { - return JSON.parse(responseText) as T - } catch (e) { - return responseText as unknown as T - } + constructor(code: string, message: string) { + super(message); + this.name = 'ApiError'; + this.code = code; + } +} + +export async function apiFetch( + path: string, + options: RequestInit = {} +): Promise { + const auth = await getAuthData() + + const isFormData = options.body instanceof FormData; + + const res = await fetch(`${API}${path}`, { + ...options, + method: options.method || 'GET', + headers: { + ...(!isFormData ? { 'Content-Type': 'application/json' } : {}), + ...(auth.token ? { Authorization: `Bearer ${auth.token}` } : {}), + ...options.headers, + }, + }) + + // Invalid token? + if (res.status === 401) { + if (auth.token) { + await clearAuthData() + router.push('/login') + } + } + + // Handle error responses + if (!res.ok) { + const text = await res.text() + + try { + const json = JSON.parse(text); + if (json && json.code && json.message) { + throw new ApiError(json.code, json.message); + } + } catch (e) { + if (e instanceof ApiError) throw e; + } + + throw new Error(text || res.statusText) + } + + const responseText = await res.text() + + if (!responseText) { + return {} as T + } + + try { + return JSON.parse(responseText) as T + } catch (e) { + return responseText as unknown as T + } } diff --git a/src/components/CreateRoomModal.vue b/src/components/CreateRoomModal.vue index 245d8c0..31a606b 100644 --- a/src/components/CreateRoomModal.vue +++ b/src/components/CreateRoomModal.vue @@ -1,95 +1,106 @@ diff --git a/src/components/InvitePeopleModal.vue b/src/components/InvitePeopleModal.vue index fc1f5bd..13098e2 100644 --- a/src/components/InvitePeopleModal.vue +++ b/src/components/InvitePeopleModal.vue @@ -33,8 +33,10 @@ import { ref } from 'vue' import { sendRoomInvite } from '../api/rooms' import { sendFriendRequest } from '../api/friends'; import { useFluent } from 'fluent-vue'; +import { useErrorTranslator } from '../errors'; const { $t } = useFluent(); +const { translateError } = useErrorTranslator(); const props = defineProps<{ room_uuid: string }>(); @@ -50,10 +52,7 @@ async function submit() { errorMessage.value = '' const username = receiverUsername.value.trim() - if (!username) { - // errorMessage.value = 'Username is required.' - return - } + if (!username) return try { await sendRoomInvite(username, props.room_uuid) @@ -67,7 +66,7 @@ async function submit() { emit('close') } catch (err: any) { - errorMessage.value = err?.message || err || $t('shared-error'); + errorMessage.value = translateError(err); } } diff --git a/src/components/UpdateAccountModal.vue b/src/components/UpdateAccountModal.vue index 3769acf..53d0fa5 100644 --- a/src/components/UpdateAccountModal.vue +++ b/src/components/UpdateAccountModal.vue @@ -41,8 +41,10 @@ import { updateSettings } from '../api/account' import { updateLocalUser } from '../store' import type { User } from '../types' import { useFluent } from 'fluent-vue'; +import { useErrorTranslator } from '../errors'; const { $t } = useFluent(); +const { translateError } = useErrorTranslator(); const props = defineProps<{ user: User | null }>() const emit = defineEmits(['close', 'updated']) @@ -84,7 +86,7 @@ async function submit() { emit('updated') emit('close') } catch (err: any) { - errorMessage.value = err?.message || $t('settings-error-failed') + errorMessage.value = translateError(err); } finally { isSubmitting.value = false } diff --git a/src/components/UploadAvatarModal.vue b/src/components/UploadAvatarModal.vue index 50f1a34..c5278e1 100644 --- a/src/components/UploadAvatarModal.vue +++ b/src/components/UploadAvatarModal.vue @@ -47,8 +47,10 @@ import { refreshLocalUser } from '../store.ts'; import { getAuthData } from '../store.ts'; import { refreshAvatar } from '../store.ts'; import { useFluent } from 'fluent-vue'; +import { useErrorTranslator } from '../errors.ts'; const { $t } = useFluent(); +const { translateError } = useErrorTranslator(); const emit = defineEmits(['close', 'updated']); @@ -166,7 +168,8 @@ async function handleUpload() { emit('close'); } catch (err: any) { console.error("Upload failed:", err); - errorMessage.value = $t('settings-error-upload-avatar-failed-upload'); + const msg = translateError(err); + errorMessage.value = msg !== 'An error occurred' ? msg : $t('settings-error-upload-avatar-failed-upload'); isSubmitting.value = false; } } diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..21b728c --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,30 @@ +import { useFluent } from 'fluent-vue'; +import { ApiError } from './api/client'; + +export function useErrorTranslator() { + const { $t } = useFluent(); + + function translateError(err: unknown): string { + if (err instanceof ApiError) { + // Convert "AUTH_INVALID_CREDENTIALS" -> "error-auth-invalid-credentials" + const key = `error-${err.code.toLowerCase().replace(/_/g, '-')}`; + + const translated = $t(key); + + // Fallback to the message provided by backend. + if (translated === key) { + return err.message; + } + + return translated; + } + + if (err instanceof Error) { + return err.message; + } + + return $t('shared-error'); + } + + return { translateError }; +} diff --git a/src/locales/en.ftl b/src/locales/en.ftl index 245a433..7816b3c 100644 --- a/src/locales/en.ftl +++ b/src/locales/en.ftl @@ -18,7 +18,6 @@ auth-login-btn = Login auth-register-btn = Create Account auth-no-account = Don't have an account? auth-has-account = Already have an account? -auth-error-unknown = An unknown error occurred auth-error-password-match = Passwords do not match auth-error-password-length = Password must be at least 8 characters long auth-error-email-invalid = Please enter a valid email address @@ -130,3 +129,34 @@ shared-confirm = Confirm ## Notifications notifications-message-title = New {$messageType} message from {$senderUsername} + +## Errors (backend) +error-auth-invalid-credentials = Invalid email or password. +error-auth-missing-token = Authentication missing. +error-auth-invalid-token = Session expired or invalid. +error-user-not-found = User not found. +error-user-email-taken = Email is already in use. +error-user-username-taken = Username is already taken. +error-user-username-length = Username must be 1-35 characters long. +error-user-invalid-email = Invalid email format. +error-user-password-too-short = Password must be at least 8 characters. +error-user-empty-fields = Required fields are empty. +error-avatar-not-found = Avatar not found. +error-room-not-found = Room not found. +error-room-name-length = Room name must be 1-35 characters long. +error-room-not-member = You are not a member of this room. +error-room-already-member = This person is already a member. +error-room-owner-cannot-leave = Owner cannot leave the room without transferring ownership. +error-room-global-no-members = Cannot list members for global rooms. +error-invite-self = You cannot invite yourself. +error-invite-already-sent = Invite already sent. +error-invite-not-found = Invite not found. +error-friend-request-self = You cannot friend request yourself. +error-friend-already-exists = You are already friends. +error-friend-request-already-sent = Friend request already pending. +error-friend-request-not-found = Friend request not found. +error-friend-not-found = User is not in your friends list. +error-internal-server-error = An error occured. +error-internal-db-error = An error occured. +error-file-too-large = The file is too large (max 2MB). +error-upload-failed = Failed to upload the file. diff --git a/src/locales/fr.ftl b/src/locales/fr.ftl index 585ef38..537aa3c 100644 --- a/src/locales/fr.ftl +++ b/src/locales/fr.ftl @@ -18,7 +18,6 @@ auth-login-btn = Se connecter auth-register-btn = Créer un compte auth-no-account = Pas encore de compte ? auth-has-account = Déjà un compte ? -auth-error-unknown = Une erreur inconnue est survenue auth-error-password-match = Les mots de passe ne correspondent pas auth-error-password-length = Le mot de passe doit faire au moins 8 caractères auth-error-email-invalid = Veuillez entrer une adresse email valide @@ -128,3 +127,34 @@ shared-confirm = Confirmer ## Notifications notifications-message-title = Nouveau message {$messageType} de {$senderUsername} + +## Errors (backend) +error-auth-invalid-credentials = Email ou mot de passe incorrect. +error-auth-missing-token = Authentification manquante. +error-auth-invalid-token = Session expirée ou invalide. +error-user-not-found = Utilisateur introuvable. +error-user-email-taken = L'adresse email est déjà utilisée. +error-user-username-taken = Ce nom d'utilisateur est déjà pris. +error-user-username-length = Le nom d'utilisateur doit faire 1-35 caractères. +error-user-invalid-email = Format d'email invalide. +error-user-password-too-short = Le mot de passe doit faire au moins 8 caractères. +error-user-empty-fields = Des champs requis sont vides. +error-avatar-not-found = Avatar introuvable. +error-room-not-found = Salon introuvable. +error-room-name-length = Le nom de la salle doit faire 1-35 caractères. +error-room-not-member = Vous n'êtes pas membre de ce salon. +error-room-already-member = Cette personne est déjà membre. +error-room-owner-cannot-leave = Le propriétaire ne peut pas quitter le salon sans transférer la propriété. +error-room-global-no-members = Impossible de lister les membres d'un salon global. +error-invite-self = Vous ne pouvez pas vous inviter vous-même. +error-invite-already-sent = Invitation déjà envoyée. +error-invite-not-found = Invitation introuvable. +error-friend-request-self = Vous ne pouvez pas vous ajouter en ami. +error-friend-already-exists = Vous êtes déjà amis. +error-friend-request-already-sent = Demande d'ami déjà en attente. +error-friend-request-not-found = Demande d'ami introuvable. +error-friend-not-found = L'utilisateur n'est pas dans votre liste d'amis. +error-internal-server-error = Une erreur est survenue. +error-internal-db-error = Une erreur est survenue. +error-file-too-large = Le fichier est trop volumineux (max 2Mo). +error-upload-failed = Erreur lors de l'envoi du fichier. diff --git a/src/main.ts b/src/main.ts index d41c425..7ab0580 100644 --- a/src/main.ts +++ b/src/main.ts @@ -54,9 +54,9 @@ async function init() { init() -// export const API = 'http://127.0.0.1:8080' +export const API = 'http://127.0.0.1:8080' // export const API = 'http://192.168.1.183:8080' -export const API = 'https://alatreon.org/frangipane' -// export const API_WS = 'ws://127.0.0.1:8080/ws' +// export const API = 'https://alatreon.org/frangipane' +export const API_WS = 'ws://127.0.0.1:8080/ws' // export const API_WS = 'ws://192.168.1.183:8080/ws' -export const API_WS = 'wss://alatreon.org/frangipane/ws' +// export const API_WS = 'wss://alatreon.org/frangipane/ws' diff --git a/src/pages/FriendListPage.vue b/src/pages/FriendListPage.vue index f1d9466..d804249 100644 --- a/src/pages/FriendListPage.vue +++ b/src/pages/FriendListPage.vue @@ -50,12 +50,15 @@ import type { Friend } from '../types' import { getAvatarUrl } from '../store' import defaultAvatar from '../assets/default-avatar.png' import UserProfileModal from '../components/UserProfileModal.vue' +import { useErrorTranslator } from '../errors' const friends = ref([]) const username = ref('') const errorMessage = ref('') const isLoading = ref(true) +const { translateError } = useErrorTranslator(); + const selectedFriend = ref(null) async function loadFriends() { @@ -91,7 +94,7 @@ async function send() { errorMessage.value = '' await loadFriends() } catch (err: any) { - errorMessage.value = err + errorMessage.value = translateError(err); } } @@ -143,7 +146,6 @@ async function send() { .friends-list { position: relative; min-width: 250px; - min-height: 200px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); diff --git a/src/pages/LoginPage.vue b/src/pages/LoginPage.vue index 7000d62..7fe1128 100644 --- a/src/pages/LoginPage.vue +++ b/src/pages/LoginPage.vue @@ -24,6 +24,7 @@ import { ref } from "vue"; import { login } from '../store.ts' import { useRouter } from "vue-router"; import { useFluent } from 'fluent-vue'; +import { useErrorTranslator } from "../errors.ts"; const email = ref(""); const password = ref(""); @@ -32,6 +33,7 @@ const errorMessage = ref(""); const router = useRouter(); const { $t } = useFluent(); +const { translateError } = useErrorTranslator(); async function submit() { errorMessage.value = ""; @@ -39,7 +41,8 @@ async function submit() { await login(email.value, "", password.value); router.push("/"); } catch (err: any) { - errorMessage.value = err?.message || $t('auth-error-unknown'); + errorMessage.value = translateError(err); + // errorMessage.value = err?.message || $t('auth-error-unknown'); } } diff --git a/src/pages/NotificationsPage.vue b/src/pages/NotificationsPage.vue index 063a462..c9d3ca3 100644 --- a/src/pages/NotificationsPage.vue +++ b/src/pages/NotificationsPage.vue @@ -47,8 +47,10 @@ import { fetchFriendRequests, acceptFriendRequest, declineFriendRequest } from ' import { fetchRoomInvites, acceptRoomInvite, declineRoomInvite } from '../api/rooms.ts' import { useNotifications } from '../store' import { useFluent } from 'fluent-vue'; +import { useErrorTranslator } from '../errors.ts'; const { $t } = useFluent(); +const { translateError } = useErrorTranslator(); const errorMessage = ref('') const { requests, invites, refreshNotifications } = useNotifications() @@ -65,7 +67,8 @@ async function acceptFriend(senderUuid: string) { requests.value = requests.value.filter(r => r.sender_uuid !== senderUuid) // fetchFriends().then(f => (friends.value = f)) } catch (err) { - errorMessage.value = $t('notifications-error-friend-accept') + const msg = translateError(err); + errorMessage.value = msg !== $t('shared-error') ? msg : $t('notifications-error-friend-accept') } } @@ -75,7 +78,8 @@ async function declineFriend(senderUuid: string) { requests.value = requests.value.filter(r => r.sender_uuid !== senderUuid) // fetchFriends().then(f => (friends.value = f)) } catch (err) { - errorMessage.value = $t('notifications-error-friend-decline') + const msg = translateError(err); + errorMessage.value = msg !== $t('shared-error') ? msg : $t('notifications-error-friend-decline') } } @@ -84,8 +88,8 @@ async function acceptRoom(senderUuid: string, roomUuid: string) { await acceptRoomInvite(senderUuid, roomUuid) invites.value = invites.value.filter(r => r.room_uuid !== roomUuid) } catch (err) { - errorMessage.value = $t('notifications-error-room-accept') - throw err + const msg = translateError(err); + errorMessage.value = msg !== $t('shared-error') ? msg : $t('notifications-error-room-accept') } } @@ -94,8 +98,8 @@ async function declineRoom(senderUuid: string, roomUuid: string) { await declineRoomInvite(senderUuid, roomUuid) invites.value = invites.value.filter(r => r.room_uuid !== roomUuid) } catch (err) { - errorMessage.value = $t('notifications-error-room-decline') - throw err + const msg = translateError(err); + errorMessage.value = msg !== $t('shared-error') ? msg : $t('notifications-error-room-decline') } } diff --git a/src/pages/RegisterPage.vue b/src/pages/RegisterPage.vue index 4ee8679..df6d422 100644 --- a/src/pages/RegisterPage.vue +++ b/src/pages/RegisterPage.vue @@ -22,7 +22,7 @@

{{ errorMessage }}

@@ -35,8 +35,10 @@ import { ref } from "vue"; import { register } from '../store.ts' import { useRouter } from "vue-router"; import { useFluent } from 'fluent-vue'; +import { useErrorTranslator } from '../errors'; const { $t } = useFluent(); +const { translateError } = useErrorTranslator(); const email = ref(""); const username = ref(""); @@ -51,17 +53,15 @@ async function submit(event: Event) { const form = event.target as HTMLFormElement; - // Check password length and email - if (!form.checkValidity()) { - if (password.value.length < 8) { - errorMessage.value = $t('auth-error-password-length'); - } else { - errorMessage.value = $t('auth-error-email-invalid'); - } - return; - } + // if (!form.checkValidity()) { + // if (password.value.length < 8) { + // errorMessage.value = $t('auth-error-password-length'); + // } else { + // errorMessage.value = $t('auth-error-email-invalid'); + // } + // return; + // } - // Check password match if (password.value !== confirmPassword.value) { errorMessage.value = $t('auth-error-password-match'); return; @@ -71,7 +71,7 @@ async function submit(event: Event) { await register(email.value, username.value, password.value); router.push("/"); } catch (err: any) { - errorMessage.value = err?.message || "An unknown error occurred"; + errorMessage.value = translateError(err); } }