diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index f1e05199..61136948 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -50,6 +50,9 @@ import SmsWalletPage from './pages/SmsWalletPage'; import MySecretariesPage from './pages/MySecretariesPage'; import AdminSubscriptionPage from './pages/AdminSubscriptionPage'; import SettingsMenuPage from './pages/SettingsMenuPage'; +import AccountSettingsPage from './pages/AccountSettingsPage'; +import TagsSettingsPage from './pages/TagsSettingsPage'; +import AppointmentSettingsPage from './pages/AppointmentSettingsPage'; import PaymentSuccessPage from './pages/PaymentSuccessPage'; import PwaInstallBanner from './components/ui/PwaInstallBanner'; @@ -195,6 +198,9 @@ export default function App() { {/* فاز ۲ — دکتر / کلینیک */} } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/SettingsLayout.test.tsx b/assets/admin/components/layout/SettingsLayout.test.tsx index 56a85a3c..163dac94 100644 --- a/assets/admin/components/layout/SettingsLayout.test.tsx +++ b/assets/admin/components/layout/SettingsLayout.test.tsx @@ -26,14 +26,14 @@ describe('SettingsLayout', () => { expect(active).toHaveAttribute('href', '/admin/subscription'); }); - it('renders not-yet-implemented items as disabled placeholders', () => { + it('renders every menu item as a navigable link', () => { renderWithProviders(
, ); - // "حساب کاربری" has no route → disabled button with "به‌زودی" - const account = screen.getByText('حساب کاربری').closest('button'); - expect(account).toBeDisabled(); - expect(screen.getAllByText('به‌زودی').length).toBeGreaterThan(0); + // all items are now wired to a route + expect(screen.getByText('مدیریت نوبت دهی').closest('a')).toHaveAttribute('href', '/admin/appointment-settings'); + expect(screen.getByText('برچسب‌ها').closest('a')).toHaveAttribute('href', '/admin/tags-settings'); + expect(screen.queryByText('به‌زودی')).not.toBeInTheDocument(); }); it('filters the menu by the search query', () => { diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 7929534f..8060f123 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -20,15 +20,15 @@ export type SettingsMenuItem = { export const SETTINGS_MENU: SettingsMenuItem[] = [ { key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription' }, { key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile' }, - { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon }, + { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings' }, { key: 'clinic', label: 'مدیریت مطب', icon: BuildingOffice2Icon, to: '/admin/my-clinic' }, { key: 'services', label: 'خدمات', icon: WrenchScrewdriverIcon, to: '/admin/clinic-services' }, { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial' }, { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' }, { key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, to: '/admin/insurance-pricing' }, - { key: 'tags', label: 'برچسب‌ها', icon: TagIcon }, + { key: 'tags', label: 'برچسب‌ها', icon: TagIcon, to: '/admin/tags-settings' }, { key: 'sms', label: 'پیامک‌ها', icon: ChatBubbleLeftRightIcon, to: '/admin/sms-wallet' }, - { key: 'account', label: 'حساب کاربری', icon: UserCircleIcon }, + { key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, to: '/admin/account-settings' }, ]; // ── Shared item styling ────────────────────────────────────────────────────── diff --git a/assets/admin/pages/AccountSettingsPage.test.tsx b/assets/admin/pages/AccountSettingsPage.test.tsx new file mode 100644 index 00000000..05638db2 --- /dev/null +++ b/assets/admin/pages/AccountSettingsPage.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; +import AccountSettingsPage from './AccountSettingsPage'; + +const post = api.post as ReturnType; + +beforeEach(() => { + post.mockReset(); + post.mockResolvedValue({ success: true, data: { message: 'ok' } }); + useAuthStore.setState({ primaryRole: 'doctor', userName: 'دکتر امینی', context: null }); +}); + +describe('AccountSettingsPage', () => { + it('shows the profile summary and change-password form in the settings shell', () => { + renderWithProviders(, { route: '/admin/account-settings' }); + expect(screen.getByText('خرید اشتراک')).toBeInTheDocument(); // shell menu + expect(screen.getAllByText('دکتر امینی').length).toBeGreaterThan(0); // profile name + expect(screen.getAllByText('تغییر رمز عبور').length).toBeGreaterThan(0); // heading + button + }); + + it('submits the change-password request with current + new password', async () => { + renderWithProviders(, { route: '/admin/account-settings' }); + + fireEvent.change(screen.getByLabelText('رمز فعلی'), { target: { value: 'oldpass12' } }); + fireEvent.change(screen.getByLabelText('رمز جدید'), { target: { value: 'newpass34' } }); + fireEvent.change(screen.getByLabelText('تکرار رمز جدید'), { target: { value: 'newpass34' } }); + fireEvent.click(screen.getByRole('button', { name: 'تغییر رمز عبور' })); + + await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/user/change-password', { + current_password: 'oldpass12', new_password: 'newpass34', + })); + }); + + it('blocks submit when the confirmation does not match', async () => { + renderWithProviders(, { route: '/admin/account-settings' }); + + fireEvent.change(screen.getByLabelText('رمز فعلی'), { target: { value: 'oldpass12' } }); + fireEvent.change(screen.getByLabelText('رمز جدید'), { target: { value: 'newpass34' } }); + fireEvent.change(screen.getByLabelText('تکرار رمز جدید'), { target: { value: 'different' } }); + fireEvent.click(screen.getByRole('button', { name: 'تغییر رمز عبور' })); + + expect(await screen.findByText('تکرار رمز مطابقت ندارد')).toBeInTheDocument(); + expect(post).not.toHaveBeenCalled(); + }); +}); diff --git a/assets/admin/pages/AccountSettingsPage.tsx b/assets/admin/pages/AccountSettingsPage.tsx new file mode 100644 index 00000000..d2dc0bc3 --- /dev/null +++ b/assets/admin/pages/AccountSettingsPage.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useMutation } from '@tanstack/react-query'; +import { UserCircleIcon, EyeIcon, EyeSlashIcon, LockClosedIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; +import SettingsLayout from '../components/layout/SettingsLayout'; + +const ROLE_LABELS: Record = { + admin: 'مدیر', clinic: 'کلینیک', doctor: 'پزشک', + secretary: 'منشی', representation: 'نماینده', user: 'کاربر', +}; + +const schema = z.object({ + current_password: z.string().min(1, 'رمز فعلی الزامی است'), + new_password: z.string().min(8, 'رمز جدید باید حداقل ۸ کاراکتر باشد'), + confirm: z.string().min(1, 'تکرار رمز الزامی است'), +}).refine((d) => d.new_password === d.confirm, { path: ['confirm'], message: 'تکرار رمز مطابقت ندارد' }) + .refine((d) => d.new_password !== d.current_password, { path: ['new_password'], message: 'رمز جدید نباید با رمز فعلی یکسان باشد' }); + +type Form = z.infer; + +/** حساب کاربری — profile summary + change-password form, inside the settings shell. */ +export default function AccountSettingsPage() { + const { userName, primaryRole, context } = useAuthStore(); + const [show, setShow] = useState<{ cur: boolean; next: boolean }>({ cur: false, next: false }); + + const form = useForm
({ resolver: zodResolver(schema) }); + + const changePassword = useMutation({ + mutationFn: (d: Form) => api.post('/api/v1/user/change-password', { + current_password: d.current_password, + new_password: d.new_password, + }), + onSuccess: () => { toast.success('رمز عبور با موفقیت تغییر یافت'); form.reset(); }, + onError: (e: any) => toast.error(e.message), + }); + + const rows: [string, string][] = [ + ['نام', userName || '—'], + ['نقش', primaryRole ? (ROLE_LABELS[primaryRole] ?? primaryRole) : '—'], + ['محیط فعلی', context?.name || '—'], + ]; + + return ( + +
+

حساب کاربری

+ + {/* Profile summary */} +
+
+
+ +
+
{userName || 'کاربر'}
+
+
+ {rows.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ + {/* Change password */} + changePassword.mutate(d))} + style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20, display: 'flex', flexDirection: 'column', gap: 16 }} + > +
+ + تغییر رمز عبور +
+ + setShow((s) => ({ ...s, cur: !s.cur }))} + register={form.register('current_password')} error={form.formState.errors.current_password?.message} + /> + setShow((s) => ({ ...s, next: !s.next }))} + register={form.register('new_password')} error={form.formState.errors.new_password?.message} + /> + setShow((s) => ({ ...s, next: !s.next }))} + register={form.register('confirm')} error={form.formState.errors.confirm?.message} + /> + + + +
+
+ ); +} + +function PasswordField({ label, show, onToggle, register, error }: { + label: string; show: boolean; onToggle: () => void; + register: ReturnType['register']>; error?: string; +}) { + return ( +
+ +
+ + +
+ {error && {error}} +
+ ); +} diff --git a/assets/admin/pages/AppointmentSettingsPage.test.tsx b/assets/admin/pages/AppointmentSettingsPage.test.tsx new file mode 100644 index 00000000..8e895c4e --- /dev/null +++ b/assets/admin/pages/AppointmentSettingsPage.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; +import AppointmentSettingsPage from './AppointmentSettingsPage'; + +const get = api.get as ReturnType; + +beforeEach(() => { + get.mockReset(); + useAuthStore.setState({ primaryRole: 'doctor', doctorUuid: 'doc-1', dbUuid: 'doc-1' }); + get.mockImplementation((url: string) => { + if (url.includes('/doctor/doc-1')) return Promise.resolve({ success: true, data: { data: { address: [] } } }); + if (url.includes('/weekly-schedule')) return Promise.resolve({ success: true, data: {} }); + return Promise.resolve({ success: true, data: {} }); + }); +}); + +describe('AppointmentSettingsPage', () => { + it('renders the weekly-schedule section inside the settings shell', async () => { + renderWithProviders(, { route: '/admin/appointment-settings' }); + // appears in the shell menu and as the page heading + expect((await screen.findAllByText('مدیریت نوبت دهی')).length).toBeGreaterThan(1); + expect(screen.getByText('خرید اشتراک')).toBeInTheDocument(); // shell menu + }); +}); diff --git a/assets/admin/pages/AppointmentSettingsPage.tsx b/assets/admin/pages/AppointmentSettingsPage.tsx new file mode 100644 index 00000000..51fd031e --- /dev/null +++ b/assets/admin/pages/AppointmentSettingsPage.tsx @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; +import SettingsLayout from '../components/layout/SettingsLayout'; +import { WeeklyScheduleTab, type AddressData } from './DoctorDetailPage'; + +/** + * مدیریت نوبت دهی — the doctor's weekly booking schedule as a standalone + * settings section. Reuses the WeeklyScheduleTab editor (also shown in the + * doctor profile) with the current doctor's uuid and addresses. + */ +export default function AppointmentSettingsPage() { + const doctorUuid = useAuthStore((s) => s.doctorUuid); + const dbUuid = useAuthStore((s) => s.dbUuid); + const uuid = doctorUuid ?? dbUuid ?? undefined; + + const { data, isLoading } = useQuery({ + queryKey: ['doctor-detail', uuid, 'appointment-settings'], + queryFn: () => api.get>(`/api/v1/doctor/${uuid}`), + enabled: !!uuid, + }); + + const doctor = (data?.data as any)?.data ?? data?.data; + const addresses: AddressData[] = doctor?.address ?? []; + + return ( + +
+

مدیریت نوبت دهی

+ + {!uuid ? ( +
+ این بخش فقط برای پزشک در دسترس است. +
+ ) : isLoading ? ( +
در حال بارگذاری...
+ ) : ( + + )} +
+
+ ); +} diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index cedf0392..85e8d5fd 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -66,7 +66,7 @@ interface ProvinceOpt { id: number; uuid: string; name: string; } interface CityOpt { id: number; uuid: string; name: string; } interface ImageFileData { fid: number; uuid: string; url: string; filename: string; filemime: string; filesize: number; } -interface AddressData { +export interface AddressData { id: string; uuid: string; type: 'personal' | 'clinic'; clinic_id: string | null; @@ -1228,7 +1228,7 @@ function SessionEditor({ session, onChange, onRemove, addresses }: { // ── Weekly Schedule Tab ──────────────────────────────────────────────────── -function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: { doctorUuid: string; addresses: AddressData[]; readOnly?: boolean }) { +export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: { doctorUuid: string; addresses: AddressData[]; readOnly?: boolean }) { const qc = useQueryClient(); const [scheduleMap, setScheduleMap] = useState(EMPTY_NEW_SCHEDULE); const [scheduleUuid, setScheduleUuid] = useState(null); diff --git a/assets/admin/pages/SettingsMenuPage.test.tsx b/assets/admin/pages/SettingsMenuPage.test.tsx index 64f8dc71..47750d25 100644 --- a/assets/admin/pages/SettingsMenuPage.test.tsx +++ b/assets/admin/pages/SettingsMenuPage.test.tsx @@ -12,11 +12,10 @@ describe('SettingsMenuPage', () => { } }); - it('links implemented sections and disables the rest', () => { + it('links every implemented section', () => { renderWithProviders(); - // implemented → anchor with href expect(screen.getByText('خرید اشتراک').closest('a')).toHaveAttribute('href', '/admin/subscription'); - // not implemented → disabled button - expect(screen.getByText('مدیریت نوبت دهی').closest('button')).toBeDisabled(); + expect(screen.getByText('مدیریت نوبت دهی').closest('a')).toHaveAttribute('href', '/admin/appointment-settings'); + expect(screen.getByText('حساب کاربری').closest('a')).toHaveAttribute('href', '/admin/account-settings'); }); }); diff --git a/assets/admin/pages/TagsSettingsPage.test.tsx b/assets/admin/pages/TagsSettingsPage.test.tsx new file mode 100644 index 00000000..47cf9db4 --- /dev/null +++ b/assets/admin/pages/TagsSettingsPage.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import TagsSettingsPage from './TagsSettingsPage'; + +const get = api.get as ReturnType; +const post = api.post as ReturnType; + +beforeEach(() => { + get.mockReset(); post.mockReset(); + get.mockResolvedValue({ success: true, data: [ + { uuid: 't1', name: 'فوری', color: '#FF0000', active: true }, + { uuid: 't2', name: 'پیگیری', color: '#00AA00', active: false }, + ] }); + post.mockResolvedValue({ success: true, data: { uuid: 't3', name: 'جدید', color: '#5559CE', active: true } }); +}); + +describe('TagsSettingsPage', () => { + it('lists tenant tags in the settings shell', async () => { + renderWithProviders(, { route: '/admin/tags-settings' }); + expect(await screen.findByText('فوری')).toBeInTheDocument(); + expect(screen.getByText('پیگیری')).toBeInTheDocument(); + expect(screen.getByText('خرید اشتراک')).toBeInTheDocument(); // shell menu + }); + + it('creates a tag through the modal', async () => { + renderWithProviders(, { route: '/admin/tags-settings' }); + await screen.findByText('فوری'); + + fireEvent.click(screen.getByRole('button', { name: /برچسب جدید/ })); + fireEvent.change(screen.getByPlaceholderText('مثلاً: فوری'), { target: { value: 'اورژانس' } }); + fireEvent.click(screen.getByRole('button', { name: 'ذخیره' })); + + await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/tenant-tag', expect.objectContaining({ name: 'اورژانس' }))); + }); +}); diff --git a/assets/admin/pages/TagsSettingsPage.tsx b/assets/admin/pages/TagsSettingsPage.tsx new file mode 100644 index 00000000..f8a72a6f --- /dev/null +++ b/assets/admin/pages/TagsSettingsPage.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { PlusIcon, PencilIcon, TrashIcon, TagIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import Modal from '../components/ui/Modal'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; +import SettingsLayout from '../components/layout/SettingsLayout'; + +interface TenantTag { uuid: string; name: string; color: string; active: boolean } + +const schema = z.object({ + name: z.string().min(1, 'نام برچسب الزامی است'), + color: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'رنگ نامعتبر است'), +}); +type Form = z.infer; + +const EMPTY: TenantTag[] = []; + +/** برچسب‌ها — per-tenant tag management inside the settings shell. */ +export default function TagsSettingsPage() { + const qc = useQueryClient(); + const [modal, setModal] = useState<'create' | TenantTag | null>(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data, isLoading } = useQuery>({ + queryKey: ['tenant-tags'], + queryFn: () => api.get('/api/v1/tenant-tags'), + }); + const tags = data?.data ?? EMPTY; + + const form = useForm
({ resolver: zodResolver(schema), defaultValues: { name: '', color: '#5559CE' } }); + + const invalidate = () => qc.invalidateQueries({ queryKey: ['tenant-tags'] }); + + const createTag = useMutation({ + mutationFn: (d: Form) => api.post('/api/v1/tenant-tag', d), + onSuccess: () => { invalidate(); setModal(null); form.reset({ name: '', color: '#5559CE' }); toast.success('برچسب ایجاد شد'); }, + onError: (e: any) => toast.error(e.message), + }); + const editTag = useMutation({ + mutationFn: ({ uuid, d }: { uuid: string; d: Form }) => api.patch(`/api/v1/tenant-tag/${uuid}`, d), + onSuccess: () => { invalidate(); setModal(null); toast.success('برچسب ویرایش شد'); }, + onError: (e: any) => toast.error(e.message), + }); + const delTag = useMutation({ + mutationFn: (uuid: string) => api.delete(`/api/v1/tenant-tag/${uuid}`), + onSuccess: () => { invalidate(); setDeleteTarget(null); toast.success('برچسب حذف شد'); }, + onError: (e: any) => { toast.error(e.message); setDeleteTarget(null); }, + }); + + const openCreate = () => { form.reset({ name: '', color: '#5559CE' }); setModal('create'); }; + const openEdit = (t: TenantTag) => { form.reset({ name: t.name, color: t.color }); setModal(t); }; + + return ( + +
+
+

برچسب‌ها

+ +
+ + {isLoading ? ( +
در حال بارگذاری...
+ ) : tags.length === 0 ? ( +
+ +
هنوز برچسبی ثبت نشده است.
+
+ ) : ( +
+ {tags.map((t, i) => ( +
+ + {t.name} + {t.active ? 'فعال' : 'غیرفعال'} + + +
+ ))} +
+ )} +
+ + setModal(null)} title={modal === 'create' ? 'برچسب جدید' : 'ویرایش برچسب'}> + { + if (modal === 'create') createTag.mutate(d); + else if (modal && typeof modal === 'object') editTag.mutate({ uuid: modal.uuid, d }); + })} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> +
+ +
+ {form.formState.errors.name && {form.formState.errors.name.message}} +
+
+ +
+ form.setValue('color', e.target.value)} style={{ width: 44, height: 38, border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'none', cursor: 'pointer' }} /> + {form.watch('color')} +
+
+
+ + +
+ +
+ + deleteTarget && delTag.mutate(deleteTarget.uuid)} + onCancel={() => setDeleteTarget(null)} + loading={delTag.isPending} + /> +
+ ); +} diff --git a/docs/api/auth.md b/docs/api/auth.md index 8c37c7e9..66e48f8f 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -629,6 +629,38 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut --- +## POST `/api/v1/user/change-password` + +تغییر رمز عبور توسط کاربرِ احرازشده (بدون OTP). رمز فعلی راستی‌آزمایی می‌شود. + +**Permission:** `IS_AUTHENTICATED_FULLY` + +### Request Body +```json +{ + "current_password": "oldpass1234", + "new_password": "newpass1234" +} +``` + +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| `current_password` | string | ✅ | باید با رمز فعلی مطابقت کند | +| `new_password` | string | ✅ | حداقل ۸ کاراکتر و متفاوت با رمز فعلی | + +### Response `200` +```json +{ "success": true, "data": { "message": "رمز عبور با موفقیت تغییر یافت" } } +``` + +### Errors +| HTTP | Code | field | Description | +|------|------|-------|-------------| +| 422 | `ERR_VALIDATION_001` | `new_password` | رمز جدید کوتاه یا برابر رمز فعلی | +| 422 | `ERR_VALIDATION_001` | `current_password` | رمز فعلی نادرست | + +--- + ## POST `/api/v1/user/reset-password` تغییر رمز عبور با تأیید هویت از طریق OTP. diff --git a/docs/api/tag.md b/docs/api/tag.md index debfcdee..cb184c35 100644 --- a/docs/api/tag.md +++ b/docs/api/tag.md @@ -144,3 +144,39 @@ Full-table JSON export and strict wipe+replace import for this category live und The admin list endpoint accepts `sort=id&order=asc|desc` to order by `id` (used by the admin «دسته‌بندی‌ها» page when clicking the «شناسه» column). Without `sort`, the default ordering (weight/name) is unchanged. + +--- + +## برچسب‌های Tenant (doctor/clinic) + +برچسب‌های اختصاصیِ هر tenant با رنگ نمایش — جدا از taxonomy سراسری بالا. همه به entity کاربر (`doctor`/`clinic`) scope می‌شوند؛ هر tenant فقط برچسب‌های خودش را می‌بیند/تغییر می‌دهد. + +**Permission:** `IS_AUTHENTICATED_FULLY` (doctor/clinic/secretary) + +### GET `/api/v1/tenant-tags` +لیست برچسب‌های tenant جاری. Response: `{ success, data: [{ uuid, name, color, active }] }` + +### POST `/api/v1/tenant-tag` +```json +{ "name": "فوری", "color": "#FF0000" } +``` +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| `name` | string | ✅ | غیرخالی، حداکثر ۶۰ | +| `color` | string | ❌ | هگز `#RRGGBB` یا `#RRGGBBAA` (پیش‌فرض `#5559CE`) | + +Response `201`: TenantTag object. + +### PATCH `/api/v1/tenant-tag/{uuid}` +فیلدهای اختیاری `name` / `color` / `active`. فقط مالک؛ در غیر این صورت `404`. + +### DELETE `/api/v1/tenant-tag/{uuid}` +حذف برچسب. فقط مالک؛ در غیر این صورت `404`. + +### Errors +| HTTP | Code | field | Description | +|------|------|-------|-------------| +| 422 | `ERR_VALIDATION_001` | `name` | نام خالی | +| 422 | `ERR_VALIDATION_001` | `color` | رنگ نامعتبر | +| 404 | `ERR_NOT_FOUND_001` | — | برچسب یافت نشد یا متعلق به tenant دیگر | +| 403 | `ERR_FORBIDDEN_001` | — | پروفایل tenant یافت نشد | diff --git a/migrations/Version20260713103729.php b/migrations/Version20260713103729.php new file mode 100644 index 00000000..a866ea10 --- /dev/null +++ b/migrations/Version20260713103729.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE tenant_tags (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, entity_type VARCHAR(20) NOT NULL, entity_id INT NOT NULL, name VARCHAR(60) NOT NULL, color VARCHAR(9) NOT NULL, active TINYINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_25D3CEF5D17F50A6 (uuid), INDEX idx_tenant_tags_owner (entity_type, entity_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE tenant_tags'); + } +} diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 56f79954..68a3de54 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -430,6 +430,35 @@ class AuthController extends BaseController return $this->success(['message' => 'رمز عبور با موفقیت تغییر یافت']); } + /** + * Change the password of the authenticated user. Requires the current + * password (verified against the stored hash); the new one must be ≥ 8 + * chars and different from the current. + */ + #[Route('/api/v1/user/change-password', methods: ['POST'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function changePassword(Request $request, #[CurrentUser] User $user): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $current = trim($data['current_password'] ?? ''); + $new = trim($data['new_password'] ?? ''); + + if (mb_strlen($new) < 8) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'رمز عبور جدید باید حداقل ۸ کاراکتر باشد', 422, 'new_password'); + } + if ($current === '' || !$this->hasher->isPasswordValid($user, $current)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'رمز عبور فعلی نادرست است', 422, 'current_password'); + } + if ($this->hasher->isPasswordValid($user, $new)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'رمز جدید نباید با رمز فعلی یکسان باشد', 422, 'new_password'); + } + + $user->setPasswordHash($this->hasher->hashPassword($user, $new)); + $this->em->flush(); + + return $this->success(['message' => 'رمز عبور با موفقیت تغییر یافت']); + } + #[OA\Post( path: '/oauth/token/refresh', summary: 'Refresh access token using a refresh token', diff --git a/src/Tag/Controller/TenantTagController.php b/src/Tag/Controller/TenantTagController.php new file mode 100644 index 00000000..af3599d7 --- /dev/null +++ b/src/Tag/Controller/TenantTagController.php @@ -0,0 +1,160 @@ +resolveEntity($user); + if ($id === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); + } + + return $this->success(array_map( + fn(TenantTag $t) => $t->toArray(), + $this->tagRepo->findByEntity($type, $id) + )); + } + + #[Route('/api/v1/tenant-tag', methods: ['POST'])] + public function create(Request $request, #[CurrentUser] User $user): JsonResponse + { + [$type, $id] = $this->resolveEntity($user); + if ($id === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + $name = trim($data['name'] ?? ''); + $color = trim($data['color'] ?? '#5559CE'); + + if ($name === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام برچسب الزامی است', 422, 'name'); + } + if (!preg_match(self::HEX, $color)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'رنگ نامعتبر است', 422, 'color'); + } + + $tag = new TenantTag($type, $id, $name, $color); + $this->tagRepo->save($tag); + + return $this->success($tag->toArray(), 201); + } + + #[Route('/api/v1/tenant-tag/{uuid}', methods: ['PATCH'])] + public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $tag = $this->ownedTag($uuid, $user); + if ($tag === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'برچسب یافت نشد', 404); + } + + $data = json_decode($request->getContent(), true) ?? []; + + if (isset($data['name'])) { + $name = trim($data['name']); + if ($name === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام برچسب الزامی است', 422, 'name'); + } + $tag->setName($name); + } + if (isset($data['color'])) { + if (!preg_match(self::HEX, trim($data['color']))) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'رنگ نامعتبر است', 422, 'color'); + } + $tag->setColor(trim($data['color'])); + } + if (isset($data['active'])) { + $tag->setActive((bool) $data['active']); + } + + $this->tagRepo->save($tag); + + return $this->success($tag->toArray()); + } + + #[Route('/api/v1/tenant-tag/{uuid}', methods: ['DELETE'])] + public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $tag = $this->ownedTag($uuid, $user); + if ($tag === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'برچسب یافت نشد', 404); + } + + $this->tagRepo->remove($tag); + + return $this->success(['message' => 'برچسب حذف شد']); + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + /** The tag only if it belongs to the caller's entity, else null. */ + private function ownedTag(string $uuid, User $user): ?TenantTag + { + [$type, $id] = $this->resolveEntity($user); + $tag = $this->tagRepo->findByUuid($uuid); + if ($tag === null || $id === null || $tag->getEntityType() !== $type || $tag->getEntityId() !== $id) { + return null; + } + return $tag; + } + + /** @return array{0: string, 1: int|null} [entityType, entityId] */ + private function resolveEntity(User $user): array + { + if ($user->hasRole('ROLE_DOCTOR')) { + $doctor = $this->doctorRepo->findByUser($user); + return ['doctor', $doctor?->getId()]; + } + if ($user->hasRole('ROLE_CLINIC')) { + $clinic = $this->clinicRepo->findByUser($user); + return ['clinic', $clinic?->getId()]; + } + if ($user->hasRole('ROLE_SECRETARY')) { + $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); + if ($dbUuid !== null) { + $clinic = $this->clinicRepo->findByUuid($dbUuid); + if ($clinic !== null) { + return ['clinic', $clinic->getId()]; + } + $doctor = $this->doctorRepo->findByUuid($dbUuid); + if ($doctor !== null) { + return ['doctor', $doctor->getId()]; + } + } + } + return ['unknown', null]; + } +} diff --git a/src/Tag/Entity/TenantTag.php b/src/Tag/Entity/TenantTag.php new file mode 100644 index 00000000..0d26255c --- /dev/null +++ b/src/Tag/Entity/TenantTag.php @@ -0,0 +1,81 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->entityType = $entityType; + $this->entityId = $entityId; + $this->name = $name; + $this->color = $color; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getEntityType(): string { return $this->entityType; } + public function getEntityId(): int { return $this->entityId; } + public function getName(): string { return $this->name; } + public function getColor(): string { return $this->color; } + public function isActive(): bool { return $this->active; } + + public function setName(string $v): self { $this->name = $v; $this->updatedAt = time(); return $this; } + public function setColor(string $v): self { $this->color = $v; $this->updatedAt = time(); return $this; } + public function setActive(bool $v): self { $this->active = $v; $this->updatedAt = time(); return $this; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'name' => $this->name, + 'color' => $this->color, + 'active' => $this->active, + ]; + } +} diff --git a/src/Tag/Repository/TenantTagRepository.php b/src/Tag/Repository/TenantTagRepository.php new file mode 100644 index 00000000..1be1f93a --- /dev/null +++ b/src/Tag/Repository/TenantTagRepository.php @@ -0,0 +1,44 @@ +findOneBy(['uuid' => $uuid]); + } + + /** @return TenantTag[] */ + public function findByEntity(string $entityType, int $entityId): array + { + return $this->createQueryBuilder('t') + ->where('t.entityType = :type AND t.entityId = :id') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->orderBy('t.name', 'ASC') + ->getQuery() + ->getResult(); + } + + public function save(TenantTag $tag): void + { + $this->getEntityManager()->persist($tag); + $this->getEntityManager()->flush(); + } + + public function remove(TenantTag $tag): void + { + $this->getEntityManager()->remove($tag); + $this->getEntityManager()->flush(); + } +} diff --git a/tests/Auth/ChangePasswordTest.php b/tests/Auth/ChangePasswordTest.php new file mode 100644 index 00000000..017266bb --- /dev/null +++ b/tests/Auth/ChangePasswordTest.php @@ -0,0 +1,71 @@ +createUser(['ROLE_DOCTOR']); + $hasher = static::getContainer()->get(UserPasswordHasherInterface::class); + $user->setPasswordHash($hasher->hashPassword($user, $password)); + $this->em->flush(); + return $user; + } + + public function testChangesPasswordWithCorrectCurrent(): void + { + $user = $this->userWithPassword('oldpass12'); + + $this->authJson('POST', '/api/v1/user/change-password', $user, [ + 'current_password' => 'oldpass12', + 'new_password' => 'newpass34', + ]); + self::assertSame(200, $this->responseCode()); + + $hasher = static::getContainer()->get(UserPasswordHasherInterface::class); + $this->em->clear(); + $reloaded = $this->em->getRepository(User::class)->find($user->getId()); + self::assertTrue($hasher->isPasswordValid($reloaded, 'newpass34')); + } + + public function testRejectsWrongCurrentPassword(): void + { + $user = $this->userWithPassword('oldpass12'); + + $this->authJson('POST', '/api/v1/user/change-password', $user, [ + 'current_password' => 'wrongpass', + 'new_password' => 'newpass34', + ]); + self::assertSame(422, $this->responseCode()); + } + + public function testRejectsShortNewPassword(): void + { + $user = $this->userWithPassword('oldpass12'); + + $this->authJson('POST', '/api/v1/user/change-password', $user, [ + 'current_password' => 'oldpass12', + 'new_password' => 'short', + ]); + self::assertSame(422, $this->responseCode()); + } + + public function testRejectsSameAsCurrent(): void + { + $user = $this->userWithPassword('oldpass12'); + + $this->authJson('POST', '/api/v1/user/change-password', $user, [ + 'current_password' => 'oldpass12', + 'new_password' => 'oldpass12', + ]); + self::assertSame(422, $this->responseCode()); + } +} diff --git a/tests/Tag/TenantTagTest.php b/tests/Tag/TenantTagTest.php new file mode 100644 index 00000000..f8e02b95 --- /dev/null +++ b/tests/Tag/TenantTagTest.php @@ -0,0 +1,78 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($user, 'دکتر'); + $this->em->persist($doctor); + $this->em->flush(); + return [$user, $doctor]; + } + + public function testCreateListUpdateDelete(): void + { + [$user] = $this->doctorUser(); + + // create + $created = $this->authJson('POST', '/api/v1/tenant-tag', $user, [ + 'name' => 'فوری', 'color' => '#FF0000', + ]); + self::assertSame(201, $this->responseCode()); + self::assertSame('فوری', $created['data']['name']); + self::assertSame('#FF0000', $created['data']['color']); + $uuid = $created['data']['uuid']; + + // list + $list = $this->authJson('GET', '/api/v1/tenant-tags', $user); + self::assertSame(200, $this->responseCode()); + self::assertSame('فوری', $list['data'][0]['name']); + + // update + $this->authJson('PATCH', '/api/v1/tenant-tag/' . $uuid, $user, [ + 'name' => 'مهم', 'color' => '#00AA00', 'active' => false, + ]); + self::assertSame(200, $this->responseCode()); + + // delete + $this->authJson('DELETE', '/api/v1/tenant-tag/' . $uuid, $user); + self::assertSame(200, $this->responseCode()); + + $after = $this->authJson('GET', '/api/v1/tenant-tags', $user); + self::assertCount(0, $after['data']); + } + + public function testRejectsInvalidNameAndColor(): void + { + [$user] = $this->doctorUser(); + + $this->authJson('POST', '/api/v1/tenant-tag', $user, ['name' => '', 'color' => '#FF0000']); + self::assertSame(422, $this->responseCode()); + + $this->authJson('POST', '/api/v1/tenant-tag', $user, ['name' => 'ok', 'color' => 'red']); + self::assertSame(422, $this->responseCode()); + } + + public function testCannotTouchAnotherTenantsTag(): void + { + [$ownerA] = $this->doctorUser(); + $created = $this->authJson('POST', '/api/v1/tenant-tag', $ownerA, ['name' => 'مال A', 'color' => '#123456']); + $uuid = $created['data']['uuid']; + + [$ownerB] = $this->doctorUser(); + $this->authJson('PATCH', '/api/v1/tenant-tag/' . $uuid, $ownerB, ['name' => 'دزدی']); + self::assertSame(404, $this->responseCode()); + + $this->authJson('DELETE', '/api/v1/tenant-tag/' . $uuid, $ownerB); + self::assertSame(404, $this->responseCode()); + } +}