diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index 38ba621..d7bee77 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -13,6 +13,7 @@ export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback'); export const ADMIN_EVENTS_PATH = adminPath('/mobile/events'); export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings'); export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile'); +export const ADMIN_PROFILE_ACCOUNT_PATH = adminPath('/mobile/profile/account'); export const ADMIN_FAQ_PATH = adminPath('/mobile/help'); export const ADMIN_BILLING_PATH = adminPath('/mobile/billing'); export const ADMIN_PACKAGE_SHOP_PATH = adminPath('/mobile/billing/shop'); diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 17c2309..c62a5f9 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -2392,7 +2392,7 @@ "mobileProfile": { "title": "Profil", "settings": "Einstellungen", - "account": "Account & Sicherheit", + "account": "Account bearbeiten", "language": "Sprache", "languageDe": "Deutsch", "languageEn": "Englisch", diff --git a/resources/js/admin/i18n/locales/de/settings.json b/resources/js/admin/i18n/locales/de/settings.json index d84595d..0499ea4 100644 --- a/resources/js/admin/i18n/locales/de/settings.json +++ b/resources/js/admin/i18n/locales/de/settings.json @@ -2,6 +2,7 @@ "profile": { "title": "Profil", "subtitle": "Verwalte deine Kontodaten und Zugangsdaten.", + "loading": "Lädt ...", "sections": { "account": { "heading": "Account-Informationen", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 95f0ad7..9a9c827 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2396,7 +2396,7 @@ "mobileProfile": { "title": "Profile", "settings": "Settings", - "account": "Account & security", + "account": "Edit account", "language": "Language", "languageDe": "Deutsch", "languageEn": "English", diff --git a/resources/js/admin/i18n/locales/en/settings.json b/resources/js/admin/i18n/locales/en/settings.json index b361c34..814b89b 100644 --- a/resources/js/admin/i18n/locales/en/settings.json +++ b/resources/js/admin/i18n/locales/en/settings.json @@ -2,6 +2,7 @@ "profile": { "title": "Profile", "subtitle": "Manage your account details and credentials.", + "loading": "Loading ...", "sections": { "account": { "heading": "Account information", diff --git a/resources/js/admin/mobile/ProfileAccountPage.tsx b/resources/js/admin/mobile/ProfileAccountPage.tsx new file mode 100644 index 0000000..918ce4b --- /dev/null +++ b/resources/js/admin/mobile/ProfileAccountPage.tsx @@ -0,0 +1,302 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { CheckCircle2, Lock, MailWarning, User } from 'lucide-react'; +import { YStack, XStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; +import toast from 'react-hot-toast'; +import { MobileShell } from './components/MobileShell'; +import { MobileCard, CTAButton, PillBadge } from './components/Primitives'; +import { MobileField, MobileInput, MobileSelect } from './components/FormControls'; +import { fetchTenantProfile, updateTenantProfile, type TenantAccountProfile } from '../api'; +import { getApiErrorMessage, getApiValidationMessage } from '../lib/apiError'; +import { ADMIN_PROFILE_PATH } from '../constants'; +import { useBackNavigation } from './hooks/useBackNavigation'; +import { useAdminTheme } from './theme'; +import i18n from '../i18n'; + +type ProfileFormState = { + name: string; + email: string; + preferredLocale: string; + currentPassword: string; + password: string; + passwordConfirmation: string; +}; + +const LOCALE_OPTIONS = [ + { value: '', labelKey: 'profile.locale.auto', fallback: 'Automatisch' }, + { value: 'de', label: 'Deutsch' }, + { value: 'en', label: 'English' }, +]; + +export default function MobileProfileAccountPage() { + const { t } = useTranslation('settings'); + const { text, muted, danger, subtle, primary, accentSoft } = useAdminTheme(); + const back = useBackNavigation(ADMIN_PROFILE_PATH); + + const [profile, setProfile] = React.useState(null); + const [form, setForm] = React.useState({ + name: '', + email: '', + preferredLocale: '', + currentPassword: '', + password: '', + passwordConfirmation: '', + }); + const [loading, setLoading] = React.useState(true); + const [savingAccount, setSavingAccount] = React.useState(false); + const [savingPassword, setSavingPassword] = React.useState(false); + const [error, setError] = React.useState(null); + const loadErrorMessage = t('profile.errors.load', 'Profil konnte nicht geladen werden.'); + + const dateFormatter = React.useMemo( + () => + new Intl.DateTimeFormat(i18n.language || 'de', { + day: '2-digit', + month: 'long', + year: 'numeric', + }), + [i18n.language], + ); + + React.useEffect(() => { + (async () => { + setLoading(true); + try { + const data = await fetchTenantProfile(); + setProfile(data); + setForm((prev) => ({ + ...prev, + name: data.name ?? '', + email: data.email ?? '', + preferredLocale: data.preferred_locale ?? '', + })); + setError(null); + } catch (err) { + setError(getApiErrorMessage(err, loadErrorMessage)); + } finally { + setLoading(false); + } + })(); + }, []); + + const verifiedAt = profile?.email_verified_at ? new Date(profile.email_verified_at) : null; + const verifiedDate = verifiedAt ? dateFormatter.format(verifiedAt) : null; + const emailStatusLabel = profile?.email_verified + ? t('profile.status.emailVerified', 'E-Mail bestätigt') + : t('profile.status.emailNotVerified', 'Bestätigung erforderlich'); + const emailHint = profile?.email_verified + ? t('profile.status.verifiedHint', 'Bestätigt am {{date}}.', { date: verifiedDate ?? '' }) + : t('profile.status.unverifiedHint', 'Bei Änderung der E-Mail senden wir dir automatisch eine neue Bestätigung.'); + + const buildPayload = (includePassword: boolean) => ({ + name: form.name.trim(), + email: form.email.trim(), + preferred_locale: form.preferredLocale ? form.preferredLocale : null, + ...(includePassword + ? { + current_password: form.currentPassword, + password: form.password, + password_confirmation: form.passwordConfirmation, + } + : {}), + }); + + const handleAccountSave = async () => { + setSavingAccount(true); + try { + const updated = await updateTenantProfile(buildPayload(false)); + setProfile(updated); + setError(null); + toast.success(t('profile.toasts.updated', 'Profil wurde aktualisiert.')); + } catch (err) { + const message = getApiValidationMessage(err, t('profile.errors.update', 'Profil konnte nicht aktualisiert werden.')); + setError(message); + toast.error(message); + } finally { + setSavingAccount(false); + } + }; + + const handlePasswordSave = async () => { + setSavingPassword(true); + try { + const updated = await updateTenantProfile(buildPayload(true)); + setProfile(updated); + setError(null); + setForm((prev) => ({ + ...prev, + currentPassword: '', + password: '', + passwordConfirmation: '', + })); + toast.success(t('profile.toasts.passwordChanged', 'Passwort wurde aktualisiert.')); + } catch (err) { + const message = getApiValidationMessage(err, t('profile.errors.update', 'Profil konnte nicht aktualisiert werden.')); + setError(message); + toast.error(message); + } finally { + setSavingPassword(false); + } + }; + + const passwordReady = + form.currentPassword.trim().length > 0 && + form.password.trim().length > 0 && + form.passwordConfirmation.trim().length > 0; + + return ( + + {error ? ( + + + {error} + + + ) : null} + + + + + + + + + {form.name || profile?.email || t('profile.title', 'Profil')} + + + {form.email || profile?.email || '—'} + + + + + {profile?.email_verified ? ( + + ) : ( + + )} + + {emailStatusLabel} + + + {emailHint} + + + + + + + + + {t('profile.sections.account.heading', 'Account-Informationen')} + + + + {t('profile.sections.account.description', 'Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an.')} + + {loading ? ( + + {t('profile.loading', 'Lädt ...')} + + ) : ( + + + setForm((prev) => ({ ...prev, name: event.target.value }))} + placeholder={t('profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')} + hasError={false} + /> + + + setForm((prev) => ({ ...prev, email: event.target.value }))} + placeholder="mail@beispiel.de" + type="email" + hasError={false} + /> + + + setForm((prev) => ({ ...prev, preferredLocale: event.target.value }))} + > + {LOCALE_OPTIONS.map((option) => ( + + ))} + + + + + )} + + + + + + + {t('profile.sections.password.heading', 'Passwort ändern')} + + + + {t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')} + + + + setForm((prev) => ({ ...prev, currentPassword: event.target.value }))} + placeholder="••••••••" + type="password" + hasError={false} + /> + + + setForm((prev) => ({ ...prev, password: event.target.value }))} + placeholder="••••••••" + type="password" + hasError={false} + /> + + + setForm((prev) => ({ ...prev, passwordConfirmation: event.target.value }))} + placeholder="••••••••" + type="password" + hasError={false} + /> + + + + + + ); +} diff --git a/resources/js/admin/mobile/ProfilePage.tsx b/resources/js/admin/mobile/ProfilePage.tsx index 8ef4728..23d58b9 100644 --- a/resources/js/admin/mobile/ProfilePage.tsx +++ b/resources/js/admin/mobile/ProfilePage.tsx @@ -12,7 +12,7 @@ import { MobileCard, CTAButton } from './components/Primitives'; import { MobileSelect } from './components/FormControls'; import { useAuth } from '../auth/context'; import { fetchTenantProfile } from '../api'; -import { adminPath, ADMIN_DATA_EXPORTS_PATH } from '../constants'; +import { adminPath, ADMIN_DATA_EXPORTS_PATH, ADMIN_PROFILE_ACCOUNT_PATH } from '../constants'; import i18n from '../i18n'; import { useAppearance } from '@/hooks/use-appearance'; import { useBackNavigation } from './hooks/useBackNavigation'; @@ -85,7 +85,7 @@ export default function MobileProfilePage() { - navigate(adminPath('/mobile/profile/security'))}> + navigate(ADMIN_PROFILE_ACCOUNT_PATH)}> - {t('mobileProfile.account', 'Account & security')} + {t('mobileProfile.account', 'Account bearbeiten')} } iconAfter={} @@ -216,4 +216,4 @@ export default function MobileProfilePage() { /> ); -} \ No newline at end of file +} diff --git a/resources/js/admin/mobile/SettingsPage.tsx b/resources/js/admin/mobile/SettingsPage.tsx index bf1a142..91054bc 100644 --- a/resources/js/admin/mobile/SettingsPage.tsx +++ b/resources/js/admin/mobile/SettingsPage.tsx @@ -16,7 +16,7 @@ import { NotificationPreferences, } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; -import { adminPath, ADMIN_HOME_PATH } from '../constants'; +import { adminPath, ADMIN_HOME_PATH, ADMIN_PROFILE_ACCOUNT_PATH } from '../constants'; import { useAdminPushSubscription } from './hooks/useAdminPushSubscription'; import { useDevicePermissions } from './hooks/useDevicePermissions'; import { type PermissionStatus, type StorageStatus } from './lib/devicePermissions'; @@ -224,7 +224,7 @@ export default function MobileSettingsPage() { {t('mobileSettings.tenantBadge', 'Tenant #{{id}}', { id: user.tenant_id })} ) : null} - navigate(adminPath('/mobile/profile'))} /> + navigate(ADMIN_PROFILE_ACCOUNT_PATH)} /> logout({ redirect: adminPath('/logout') })} /> diff --git a/resources/js/admin/mobile/__tests__/ProfileAccountPage.test.tsx b/resources/js/admin/mobile/__tests__/ProfileAccountPage.test.tsx new file mode 100644 index 0000000..4129eed --- /dev/null +++ b/resources/js/admin/mobile/__tests__/ProfileAccountPage.test.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const backMock = vi.fn(); + +vi.mock('../hooks/useBackNavigation', () => ({ + useBackNavigation: () => backMock, +})); + +vi.mock('../../api', () => ({ + fetchTenantProfile: vi.fn(), + updateTenantProfile: vi.fn(), +})); + +vi.mock('react-hot-toast', () => ({ + default: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock('../components/MobileShell', () => ({ + MobileShell: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('../components/Primitives', () => ({ + MobileCard: ({ children }: { children: React.ReactNode }) =>
{children}
, + PillBadge: ({ children }: { children: React.ReactNode }) =>
{children}
, + CTAButton: ({ + label, + onPress, + disabled, + }: { + label: string; + onPress?: () => void; + disabled?: boolean; + }) => ( + + ), +})); + +vi.mock('../components/FormControls', () => ({ + MobileField: ({ children }: { children: React.ReactNode }) =>
{children}
, + MobileInput: ({ hasError, compact, ...props }: React.InputHTMLAttributes & { hasError?: boolean; compact?: boolean }) => ( + + ), + MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => , +})); + +vi.mock('@tamagui/stacks', () => ({ + YStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + XStack: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/text', () => ({ + SizableText: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('../theme', () => ({ + useAdminTheme: () => ({ + text: '#111827', + muted: '#6b7280', + subtle: '#94a3b8', + danger: '#b91c1c', + border: '#e5e7eb', + surface: '#ffffff', + primary: '#ff5a5f', + accentSoft: '#fde7ea', + }), +})); + +import { fetchTenantProfile, updateTenantProfile } from '../../api'; +import MobileProfileAccountPage from '../ProfileAccountPage'; + +const profileFixture = { + id: 1, + name: 'Test Admin', + email: 'admin@example.com', + preferred_locale: null, + email_verified: true, + email_verified_at: '2024-01-02T00:00:00.000Z', +}; + +describe('MobileProfileAccountPage', () => { + it('submits account updates with name, email, and locale', async () => { + vi.mocked(fetchTenantProfile).mockResolvedValue(profileFixture); + vi.mocked(updateTenantProfile).mockResolvedValue(profileFixture); + + await act(async () => { + render(); + }); + await screen.findByDisplayValue('Test Admin'); + + await act(async () => { + fireEvent.click(screen.getByText('profile.actions.save')); + }); + + expect(updateTenantProfile).toHaveBeenCalledWith({ + name: 'Test Admin', + email: 'admin@example.com', + preferred_locale: null, + }); + }); + + it('submits password updates when all password fields are provided', async () => { + vi.mocked(fetchTenantProfile).mockResolvedValue(profileFixture); + vi.mocked(updateTenantProfile).mockResolvedValue(profileFixture); + + await act(async () => { + render(); + }); + await screen.findByDisplayValue('Test Admin'); + + const passwordInputs = screen.getAllByPlaceholderText('••••••••'); + await act(async () => { + fireEvent.change(passwordInputs[0], { target: { value: 'old-pass' } }); + fireEvent.change(passwordInputs[1], { target: { value: 'new-pass-123' } }); + fireEvent.change(passwordInputs[2], { target: { value: 'new-pass-123' } }); + }); + + await waitFor(() => { + expect(screen.getByText('profile.actions.updatePassword')).not.toBeDisabled(); + }); + + await act(async () => { + fireEvent.click(screen.getByText('profile.actions.updatePassword')); + }); + + expect(updateTenantProfile).toHaveBeenCalledWith({ + name: 'Test Admin', + email: 'admin@example.com', + preferred_locale: null, + current_password: 'old-pass', + password: 'new-pass-123', + password_confirmation: 'new-pass-123', + }); + }); +}); diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index eaf53d4..699bc91 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -35,6 +35,7 @@ const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage')) const MobileEventAnalyticsPage = React.lazy(() => import('./mobile/EventAnalyticsPage')); const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage')); const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage')); +const MobileProfileAccountPage = React.lazy(() => import('./mobile/ProfileAccountPage')); const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage')); const MobilePackageShopPage = React.lazy(() => import('./mobile/PackageShopPage')); const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage')); @@ -212,6 +213,7 @@ export const router = createBrowserRouter([ { path: 'mobile/notifications', element: }, { path: 'mobile/notifications/:notificationId', element: }, { path: 'mobile/profile', element: }, + { path: 'mobile/profile/account', element: }, { path: 'mobile/billing', element: }, { path: 'mobile/billing/shop', element: }, { path: 'mobile/settings', element: },