fixed response data for login,
fixed tabs
This commit is contained in:
13
AGENTS.md
13
AGENTS.md
@ -25,6 +25,8 @@ It describes what the project is, what is already implemented, and what still ne
|
|||||||
- `frontend/src/router/index.ts` maps `/` to `frontend/src/views/AuthView.vue`.
|
- `frontend/src/router/index.ts` maps `/` to `frontend/src/views/AuthView.vue`.
|
||||||
- `AuthView` serves as login + registration entry (single form, email + password).
|
- `AuthView` serves as login + registration entry (single form, email + password).
|
||||||
- Successful login stores `token` and `user_email` in `localStorage`.
|
- Successful login stores `token` and `user_email` in `localStorage`.
|
||||||
|
- `frontend/src/views/AuthView.vue` formatting now uses tab-based indentation.
|
||||||
|
- Login response parsing in `AuthView` now reads user fields from `response.data.user`.
|
||||||
- Frontend i18n is wired:
|
- Frontend i18n is wired:
|
||||||
- setup in `frontend/src/i18n/index.ts`
|
- setup in `frontend/src/i18n/index.ts`
|
||||||
- locale files in `frontend/src/locales/{sk,cs,en,es,de}.ts`
|
- locale files in `frontend/src/locales/{sk,cs,en,es,de}.ts`
|
||||||
@ -34,8 +36,7 @@ It describes what the project is, what is already implemented, and what still ne
|
|||||||
- design tokens in `frontend/src/assets/css/style.css` (`:root` variables).
|
- design tokens in `frontend/src/assets/css/style.css` (`:root` variables).
|
||||||
- App logo is served from `frontend/public/Nutrio.png` (copied from `doc/Nutrio.png`).
|
- App logo is served from `frontend/public/Nutrio.png` (copied from `doc/Nutrio.png`).
|
||||||
- Font Awesome is installed and registered globally in `frontend/src/main.ts`.
|
- Font Awesome is installed and registered globally in `frontend/src/main.ts`.
|
||||||
- `frontend/src/BackendAPI.js` is generated via `backend/scripts/buildTypeScript.php` and should not be edited manually.
|
- `frontend/src/BackendAPI.ts` is generated via `backend/scripts/buildTypeScript.php` and should not be edited manually.
|
||||||
- `frontend/src/BackendAPI.d.ts` provides TS declarations for generated `BackendAPI.js`.
|
|
||||||
- `backend/data.json` contains sample meal data (not currently wired into DB/API flow).
|
- `backend/data.json` contains sample meal data (not currently wired into DB/API flow).
|
||||||
|
|
||||||
## Backend Architecture
|
## Backend Architecture
|
||||||
@ -139,8 +140,11 @@ All actions are invoked through `backend/public/API.php` with `?action=<method_n
|
|||||||
- Some comments in `Maintenance.php` show encoding artifacts, but SQL structure is valid.
|
- Some comments in `Maintenance.php` show encoding artifacts, but SQL structure is valid.
|
||||||
- Basic token auth is implemented, but token is still passed as plain API parameter.
|
- Basic token auth is implemented, but token is still passed as plain API parameter.
|
||||||
- For `array` parameters (for example `ordered_item_ids`), APIlite expects JSON in request payload.
|
- For `array` parameters (for example `ordered_item_ids`), APIlite expects JSON in request payload.
|
||||||
- APIlite wraps responses with a nested `data` object. Keep this in mind on frontend parsing.
|
- APIlite response handling detail:
|
||||||
- `frontend/src/BackendAPI.js` is generated output; regenerate when backend API changes, do not patch manually.
|
- raw API response is wrapped as `{ status, data }`
|
||||||
|
- generated `BackendAPI.ts` currently resolves `response.data` in `callPromise` for non-`__HELP__` actions
|
||||||
|
- frontend parsing must match the actual returned runtime shape
|
||||||
|
- `frontend/src/BackendAPI.ts` is generated output; regenerate when backend API changes, do not patch manually.
|
||||||
- In vue-i18n locale strings, `@` must be escaped as `{'@'}` to avoid "Invalid linked format" errors.
|
- In vue-i18n locale strings, `@` must be escaped as `{'@'}` to avoid "Invalid linked format" errors.
|
||||||
|
|
||||||
## Local Runbook
|
## Local Runbook
|
||||||
@ -176,3 +180,4 @@ Frontend:
|
|||||||
- Keep MySQL + SQLite compatibility in SQL where possible (project supports both).
|
- Keep MySQL + SQLite compatibility in SQL where possible (project supports both).
|
||||||
- When changing schema, always bump DB version in `Maintenance.php` with forward-only migration steps.
|
- When changing schema, always bump DB version in `Maintenance.php` with forward-only migration steps.
|
||||||
- Keep API action names stable unless frontend is updated at the same time.
|
- Keep API action names stable unless frontend is updated at the same time.
|
||||||
|
- In source files, use tab characters for indentation (do not add space-based indentation).
|
||||||
|
|||||||
@ -2,14 +2,14 @@
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import {
|
import {
|
||||||
faEnvelope,
|
faEnvelope,
|
||||||
faEye,
|
faEye,
|
||||||
faEyeSlash,
|
faEyeSlash,
|
||||||
faGlobe,
|
faGlobe,
|
||||||
faLock,
|
faLock,
|
||||||
faMoon,
|
faMoon,
|
||||||
faRightToBracket,
|
faRightToBracket,
|
||||||
faSun,
|
faSun,
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
import BackendAPI from '@/BackendAPI.ts'
|
import BackendAPI from '@/BackendAPI.ts'
|
||||||
@ -19,11 +19,13 @@ import { SUPPORTED_LOCALES, type AppLocale } from '@/i18n'
|
|||||||
type ThemeMode = 'light' | 'dark'
|
type ThemeMode = 'light' | 'dark'
|
||||||
|
|
||||||
type LoginResponse = {
|
type LoginResponse = {
|
||||||
auto_registered?: boolean
|
data?: {
|
||||||
user?: {
|
auto_registered?: boolean
|
||||||
email?: string | null
|
user?: {
|
||||||
token?: string | null
|
email?: string | null
|
||||||
}
|
token?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { t } = useI18n({ useScope: 'global' })
|
const { t } = useI18n({ useScope: 'global' })
|
||||||
@ -36,199 +38,187 @@ const errorMessage = ref('')
|
|||||||
const successMessage = ref('')
|
const successMessage = ref('')
|
||||||
|
|
||||||
const isSupportedLocale = (value: string): value is AppLocale => {
|
const isSupportedLocale = (value: string): value is AppLocale => {
|
||||||
return SUPPORTED_LOCALES.includes(value as AppLocale)
|
return SUPPORTED_LOCALES.includes(value as AppLocale)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setLocale = (value: string) => {
|
const setLocale = (value: string) => {
|
||||||
if (!isSupportedLocale(value)) {
|
if (!isSupportedLocale(value)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
i18n.global.locale.value = value
|
i18n.global.locale.value = value
|
||||||
document.documentElement.setAttribute('lang', value)
|
document.documentElement.setAttribute('lang', value)
|
||||||
localStorage.setItem('locale', value)
|
localStorage.setItem('locale', value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const localeValue = computed({
|
const localeValue = computed({
|
||||||
get: () => i18n.global.locale.value as AppLocale,
|
get: () => i18n.global.locale.value as AppLocale,
|
||||||
set: (value: string) => setLocale(value),
|
set: (value: string) => setLocale(value),
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => i18n.global.locale.value,
|
() => i18n.global.locale.value,
|
||||||
(nextLocale) => {
|
(nextLocale) => {
|
||||||
document.documentElement.setAttribute('lang', nextLocale)
|
document.documentElement.setAttribute('lang', nextLocale)
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const getInitialTheme = (): ThemeMode => {
|
const getInitialTheme = (): ThemeMode => {
|
||||||
const storedTheme = localStorage.getItem('theme')
|
const storedTheme = localStorage.getItem('theme')
|
||||||
return storedTheme === 'dark' ? 'dark' : 'light'
|
return storedTheme === 'dark' ? 'dark' : 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
const theme = ref<ThemeMode>(getInitialTheme())
|
const theme = ref<ThemeMode>(getInitialTheme())
|
||||||
|
|
||||||
const applyTheme = (nextTheme: ThemeMode) => {
|
const applyTheme = (nextTheme: ThemeMode) => {
|
||||||
theme.value = nextTheme
|
theme.value = nextTheme
|
||||||
document.documentElement.setAttribute('data-theme', nextTheme)
|
document.documentElement.setAttribute('data-theme', nextTheme)
|
||||||
localStorage.setItem('theme', nextTheme)
|
localStorage.setItem('theme', nextTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
applyTheme(theme.value)
|
applyTheme(theme.value)
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
applyTheme(theme.value === 'dark' ? 'light' : 'dark')
|
applyTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDarkMode = computed(() => theme.value === 'dark')
|
const isDarkMode = computed(() => theme.value === 'dark')
|
||||||
|
|
||||||
const themeLabel = computed(() => {
|
const themeLabel = computed(() => {
|
||||||
return isDarkMode.value ? t('theme.dark') : t('theme.light')
|
return isDarkMode.value ? t('theme.dark') : t('theme.light')
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitLabel = computed(() => {
|
const submitLabel = computed(() => {
|
||||||
return isLoading.value ? t('auth.submitting') : t('auth.submit')
|
return isLoading.value ? t('auth.submitting') : t('auth.submit')
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapApiError = (error: unknown): string => {
|
const mapApiError = (error: unknown): string => {
|
||||||
if (typeof error !== 'string') {
|
if (typeof error !== 'string') {
|
||||||
return t('auth.errors.loginFailed')
|
return t('auth.errors.loginFailed')
|
||||||
}
|
}
|
||||||
if (error === 'Invalid email or password') {
|
if (error === 'Invalid email or password') {
|
||||||
return t('auth.errors.invalidCredentials')
|
return t('auth.errors.invalidCredentials')
|
||||||
}
|
}
|
||||||
if (error === 'Invalid email format') {
|
if (error === 'Invalid email format') {
|
||||||
return t('auth.errors.invalidEmail')
|
return t('auth.errors.invalidEmail')
|
||||||
}
|
}
|
||||||
return t('auth.errors.loginFailed')
|
return t('auth.errors.loginFailed')
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitForm = async () => {
|
const submitForm = async () => {
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
successMessage.value = ''
|
successMessage.value = ''
|
||||||
|
|
||||||
const normalizedEmail = email.value.trim().toLowerCase()
|
const normalizedEmail = email.value.trim().toLowerCase()
|
||||||
const normalizedPassword = password.value
|
const normalizedPassword = password.value
|
||||||
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
if (!emailRegex.test(normalizedEmail)) {
|
if (!emailRegex.test(normalizedEmail)) {
|
||||||
errorMessage.value = t('auth.errors.invalidEmail')
|
errorMessage.value = t('auth.errors.invalidEmail')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedPassword.length <= 0) {
|
if (normalizedPassword.length <= 0) {
|
||||||
errorMessage.value = t('auth.errors.passwordRequired')
|
errorMessage.value = t('auth.errors.passwordRequired')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
const response = (await BackendAPI.userLogin(normalizedEmail, normalizedPassword)) as LoginResponse
|
const response = (await BackendAPI.userLogin(
|
||||||
const token = response.user?.token ?? null
|
normalizedEmail,
|
||||||
const userEmail = response.user?.email ?? normalizedEmail
|
normalizedPassword,
|
||||||
|
)) as LoginResponse
|
||||||
|
const token = response.data?.user?.token ?? null
|
||||||
|
const userEmail = response.data?.user?.email ?? normalizedEmail
|
||||||
|
console.log(response)
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem('token', token)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
localStorage.setItem('user_email', userEmail)
|
||||||
|
|
||||||
if (token) {
|
successMessage.value = response.auto_registered
|
||||||
localStorage.setItem('token', token)
|
? t('auth.successAutoRegistered')
|
||||||
} else {
|
: t('auth.successLoggedIn')
|
||||||
localStorage.removeItem('token')
|
|
||||||
}
|
|
||||||
localStorage.setItem('user_email', userEmail)
|
|
||||||
|
|
||||||
successMessage.value = response.auto_registered
|
email.value = normalizedEmail
|
||||||
? t('auth.successAutoRegistered')
|
password.value = ''
|
||||||
: t('auth.successLoggedIn')
|
showPassword.value = false
|
||||||
|
} catch (error) {
|
||||||
email.value = normalizedEmail
|
errorMessage.value = mapApiError(error)
|
||||||
password.value = ''
|
} finally {
|
||||||
showPassword.value = false
|
isLoading.value = false
|
||||||
} catch (error) {
|
}
|
||||||
errorMessage.value = mapApiError(error)
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="auth-page">
|
<main class="auth-page">
|
||||||
<div class="auth-shell">
|
<div class="auth-shell">
|
||||||
<section class="auth-brand">
|
<section class="auth-brand">
|
||||||
<img src="/Nutrio.png" :alt="t('app.name')" class="auth-logo" />
|
<img src="/Nutrio.png" :alt="t('app.name')" class="auth-logo" />
|
||||||
<h1>{{ t('app.name') }}</h1>
|
<h1>{{ t('app.name') }}</h1>
|
||||||
<p>{{ t('app.slogan') }}</p>
|
<p>{{ t('app.slogan') }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="auth-card">
|
<section class="auth-card">
|
||||||
<div class="auth-toolbar">
|
<div class="auth-toolbar">
|
||||||
<label class="locale-control" :aria-label="t('auth.languageLabel')">
|
<label class="locale-control" :aria-label="t('auth.languageLabel')">
|
||||||
<font-awesome-icon :icon="faGlobe" />
|
<font-awesome-icon :icon="faGlobe" />
|
||||||
<span>{{ t('auth.languageLabel') }}</span>
|
<span>{{ t('auth.languageLabel') }}</span>
|
||||||
<select v-model="localeValue">
|
<select v-model="localeValue">
|
||||||
<option v-for="lang in SUPPORTED_LOCALES" :key="lang" :value="lang">
|
<option v-for="lang in SUPPORTED_LOCALES" :key="lang" :value="lang">
|
||||||
{{ t(`locale.${lang}`) }}
|
{{ t(`locale.${lang}`) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="button" class="theme-btn" :aria-label="t('auth.themeToggle')" @click="toggleTheme">
|
<button type="button" class="theme-btn" :aria-label="t('auth.themeToggle')" @click="toggleTheme">
|
||||||
<font-awesome-icon :icon="isDarkMode ? faMoon : faSun" />
|
<font-awesome-icon :icon="isDarkMode ? faMoon : faSun" />
|
||||||
<span>{{ themeLabel }}</span>
|
<span>{{ themeLabel }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>{{ t('auth.title') }}</h2>
|
<h2>{{ t('auth.title') }}</h2>
|
||||||
<p class="auth-subtitle">{{ t('auth.subtitle') }}</p>
|
<p class="auth-subtitle">{{ t('auth.subtitle') }}</p>
|
||||||
|
|
||||||
<form class="auth-form" @submit.prevent="submitForm">
|
<form class="auth-form" @submit.prevent="submitForm">
|
||||||
<label for="email">{{ t('auth.emailLabel') }}</label>
|
<label for="email">{{ t('auth.emailLabel') }}</label>
|
||||||
<div class="input-wrap">
|
<div class="input-wrap">
|
||||||
<font-awesome-icon :icon="faEnvelope" class="input-icon" />
|
<font-awesome-icon :icon="faEnvelope" class="input-icon" />
|
||||||
<input
|
<input id="email" v-model="email" type="email" autocomplete="email"
|
||||||
id="email"
|
:placeholder="t('auth.emailPlaceholder')" required />
|
||||||
v-model="email"
|
</div>
|
||||||
type="email"
|
|
||||||
autocomplete="email"
|
|
||||||
:placeholder="t('auth.emailPlaceholder')"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label for="password">{{ t('auth.passwordLabel') }}</label>
|
<label for="password">{{ t('auth.passwordLabel') }}</label>
|
||||||
<div class="input-wrap">
|
<div class="input-wrap">
|
||||||
<font-awesome-icon :icon="faLock" class="input-icon" />
|
<font-awesome-icon :icon="faLock" class="input-icon" />
|
||||||
<input
|
<input id="password" v-model="password" :type="showPassword ? 'text' : 'password'"
|
||||||
id="password"
|
autocomplete="current-password" :placeholder="t('auth.passwordPlaceholder')" required />
|
||||||
v-model="password"
|
<button type="button" class="password-btn"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:aria-label="showPassword ? t('auth.hidePassword') : t('auth.showPassword')"
|
||||||
autocomplete="current-password"
|
@click="showPassword = !showPassword">
|
||||||
:placeholder="t('auth.passwordPlaceholder')"
|
<font-awesome-icon :icon="showPassword ? faEyeSlash : faEye" />
|
||||||
required
|
</button>
|
||||||
/>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="password-btn"
|
|
||||||
:aria-label="showPassword ? t('auth.hidePassword') : t('auth.showPassword')"
|
|
||||||
@click="showPassword = !showPassword"
|
|
||||||
>
|
|
||||||
<font-awesome-icon :icon="showPassword ? faEyeSlash : faEye" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="submit-btn" type="submit" :disabled="isLoading">
|
<button class="submit-btn" type="submit" :disabled="isLoading">
|
||||||
<font-awesome-icon :icon="faRightToBracket" />
|
<font-awesome-icon :icon="faRightToBracket" />
|
||||||
<span>{{ submitLabel }}</span>
|
<span>{{ submitLabel }}</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="auth-helper">{{ t('auth.helper') }}</p>
|
<p class="auth-helper">{{ t('auth.helper') }}</p>
|
||||||
|
|
||||||
<p v-if="errorMessage.length > 0" class="feedback feedback-error">{{ errorMessage }}</p>
|
<p v-if="errorMessage.length > 0" class="feedback feedback-error">{{ errorMessage }}</p>
|
||||||
|
|
||||||
<p v-if="successMessage.length > 0" class="feedback feedback-success">
|
<p v-if="successMessage.length > 0" class="feedback feedback-success">
|
||||||
{{ successMessage }} {{ t('auth.tokenSaved') }}
|
{{ successMessage }} {{ t('auth.tokenSaved') }}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
Reference in New Issue
Block a user