diff --git a/backend/src/db/migrations/029_org_profile_fields.sql b/backend/src/db/migrations/029_org_profile_fields.sql new file mode 100644 index 00000000..4d6d1109 --- /dev/null +++ b/backend/src/db/migrations/029_org_profile_fields.sql @@ -0,0 +1,6 @@ +-- Organization profile fields for the Settings page. +-- Contact info is optional and managed by org admins via the Settings UI. + +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255), + ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(50); diff --git a/backend/src/routes/organizationRoutes.ts b/backend/src/routes/organizationRoutes.ts new file mode 100644 index 00000000..52828f11 --- /dev/null +++ b/backend/src/routes/organizationRoutes.ts @@ -0,0 +1,114 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../config/database.js'; +import { authenticateJWT } from '../middlewares/auth.js'; +import { authorizeRoles, isolateOrganization } from '../middlewares/rbac.js'; +import logger from '../utils/logger.js'; + +const router = Router(); + +router.use(authenticateJWT); +router.use(isolateOrganization); + +const getOrganizationId = (req: Request): number | null => + req.user?.organizationId ?? req.tenantId ?? null; + +const serializeOrgProfile = (row: any) => ({ + id: row.id, + name: row.name, + publicKey: row.public_key ?? null, + contactEmail: row.contact_email ?? null, + contactPhone: row.contact_phone ?? null, + isActive: row.is_active ?? true, + subscriptionTier: row.subscription_tier ?? 'free', + createdAt: row.created_at ?? null, + updatedAt: row.updated_at ?? null, +}); + +/** + * GET /api/v1/organizations/profile + * Return the calling organization's profile. + */ +router.get( + '/profile', + authorizeRoles('EMPLOYER', 'ADMIN'), + async (req: Request, res: Response) => { + const organizationId = getOrganizationId(req); + if (!organizationId) { + return res.status(400).json({ error: 'Organization context required' }); + } + + try { + const result = await pool.query( + `SELECT id, name, public_key, contact_email, contact_phone, is_active, subscription_tier, created_at, updated_at + FROM organizations + WHERE id = $1`, + [organizationId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Organization not found' }); + } + + res.json({ organization: serializeOrgProfile(result.rows[0]) }); + } catch (err: any) { + logger.error('Failed to fetch organization profile', { err, organizationId }); + res.status(500).json({ error: 'Failed to fetch organization profile' }); + } + } +); + +/** + * PUT /api/v1/organizations/profile + * Update the calling organization's editable profile fields. + */ +router.put( + '/profile', + authorizeRoles('EMPLOYER', 'ADMIN'), + async (req: Request, res: Response) => { + const organizationId = getOrganizationId(req); + if (!organizationId) { + return res.status(400).json({ error: 'Organization context required' }); + } + + const { name, contactEmail, contactPhone } = req.body ?? {}; + + if (name !== undefined && (typeof name !== 'string' || name.trim().length === 0)) { + return res.status(400).json({ error: 'Organization name must be a non-empty string' }); + } + if (contactEmail !== undefined && typeof contactEmail !== 'string') { + return res.status(400).json({ error: 'Contact email must be a string' }); + } + if (contactPhone !== undefined && typeof contactPhone !== 'string') { + return res.status(400).json({ error: 'Contact phone must be a string' }); + } + + try { + const result = await pool.query( + `UPDATE organizations + SET name = COALESCE($2, name), + contact_email = COALESCE($3, contact_email), + contact_phone = COALESCE($4, contact_phone), + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING id, name, public_key, contact_email, contact_phone, is_active, subscription_tier, created_at, updated_at`, + [ + organizationId, + name !== undefined ? name.trim() : null, + contactEmail !== undefined ? contactEmail.trim() || null : null, + contactPhone !== undefined ? contactPhone.trim() || null : null, + ] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Organization not found' }); + } + + res.json({ organization: serializeOrgProfile(result.rows[0]) }); + } catch (err: any) { + logger.error('Failed to update organization profile', { err, organizationId }); + res.status(500).json({ error: 'Failed to update organization profile' }); + } + } +); + +export default router; diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index 76467122..19a420c7 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -21,6 +21,7 @@ import freezeRoutes from '../freezeRoutes.js'; import contractUpgradeRoutes from '../contractUpgradeRoutes.js'; import forecastRoutes from '../forecastRoutes.js'; import benefitsRoutes from '../benefitsRoutes.js'; +import organizationRoutes from '../organizationRoutes.js'; const router = Router(); @@ -44,5 +45,6 @@ router.use('/rate-limit', apiRateLimit(), rateLimitRoutes); router.use('/freeze', apiRateLimit(), freezeRoutes); router.use('/contracts', apiRateLimit(), contractUpgradeRoutes); router.use('/benefits', dataRateLimit(), benefitsRoutes); +router.use('/organizations', dataRateLimit(), organizationRoutes); export default router; diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index 4d7b6cba..d3ec719e 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -135,6 +135,20 @@ }, "settings": { "title": "Profile and Settings", + "organizationSectionTitle": "Organization Profile", + "organizationSectionDescription": "Manage your organization's basic profile information shown across PayD.", + "organizationIdLabel": "Organization ID", + "orgNameLabel": "Organization Name", + "orgContactEmailLabel": "Contact Email", + "orgContactPhoneLabel": "Contact Phone", + "orgNameRequired": "Organization name is required.", + "loading": "Loading organization profile…", + "loadError": "Failed to load the organization profile. Please try again.", + "retry": "Retry", + "save": "Save Changes", + "saving": "Saving…", + "saveSuccess": "Organization profile saved successfully.", + "saveError": "Failed to save the organization profile. Please try again.", "languageLabel": "Language", "languageDescription": "Choose your preferred language for the dashboard.", "languageEnglish": "English", diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index ef50c2df..76d5ee60 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -135,6 +135,20 @@ }, "settings": { "title": "Perfil y configuración", + "organizationSectionTitle": "Perfil de la organización", + "organizationSectionDescription": "Gestiona la información básica del perfil de tu organización visible en PayD.", + "organizationIdLabel": "ID de la organización", + "orgNameLabel": "Nombre de la organización", + "orgContactEmailLabel": "Correo de contacto", + "orgContactPhoneLabel": "Teléfono de contacto", + "orgNameRequired": "El nombre de la organización es obligatorio.", + "loading": "Cargando perfil de la organización…", + "loadError": "No se pudo cargar el perfil de la organización. Inténtalo de nuevo.", + "retry": "Reintentar", + "save": "Guardar cambios", + "saving": "Guardando…", + "saveSuccess": "Perfil de la organización guardado correctamente.", + "saveError": "No se pudo guardar el perfil de la organización. Inténtalo de nuevo.", "languageLabel": "Idioma", "languageDescription": "Elige tu idioma preferido para el panel.", "languageEnglish": "Inglés", diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index c27ba70b..50620712 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; -import { Webhook, ShieldCheck } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { getOrgProfile, updateOrgProfile, type OrgProfile } from '../services/orgApi'; + +type SaveStatus = 'idle' | 'saving' | 'success' | 'error'; export default function Settings() { const { t, i18n } = useTranslation(); @@ -9,6 +11,67 @@ export default function Settings() { void i18n.changeLanguage(event.target.value); }; + const [profile, setProfile] = useState(null); + const [form, setForm] = useState({ name: '', contactEmail: '', contactPhone: '' }); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const [saveStatus, setSaveStatus] = useState('idle'); + const [validationError, setValidationError] = useState(null); + + const loadProfile = useCallback(async () => { + setLoading(true); + setLoadError(false); + try { + const org = await getOrgProfile(); + setProfile(org); + setForm({ + name: org.name ?? '', + contactEmail: org.contactEmail ?? '', + contactPhone: org.contactPhone ?? '', + }); + } catch { + setLoadError(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadProfile(); + }, [loadProfile]); + + const handleFieldChange = (field: keyof typeof form, value: string) => { + setForm((prev) => ({ ...prev, [field]: value })); + setValidationError(null); + if (saveStatus === 'success' || saveStatus === 'error') { + setSaveStatus('idle'); + } + }; + + const handleSave = async () => { + if (!form.name.trim()) { + setValidationError(t('settings.orgNameRequired')); + return; + } + + setSaveStatus('saving'); + setValidationError(null); + try { + const updated = await updateOrgProfile({ + name: form.name.trim(), + contactEmail: form.contactEmail.trim() || undefined, + contactPhone: form.contactPhone.trim() || undefined, + }); + setProfile(updated); + setSaveStatus('success'); + } catch { + setSaveStatus('error'); + } + }; + + const inputClassName = + 'w-full bg-black/20 border border-hi rounded-xl p-4 text-text outline-none focus:border-accent/50 focus:bg-accent/5 transition-all'; + return (
@@ -17,6 +80,91 @@ export default function Settings() {
+
+
+

+ {t('settings.organizationSectionTitle')} +

+

{t('settings.organizationSectionDescription')}

+
+ + {loading ? ( +

{t('settings.loading')}

+ ) : loadError ? ( +
+

{t('settings.loadError')}

+ +
+ ) : ( +
+ {profile && ( +

+ {t('settings.organizationIdLabel')}: {profile.id} +

+ )} + +
+ + handleFieldChange('name', event.target.value)} + className={inputClassName} + /> +
+ +
+ + handleFieldChange('contactEmail', event.target.value)} + placeholder="admin@example.com" + className={inputClassName} + /> +
+ +
+ + handleFieldChange('contactPhone', event.target.value)} + placeholder="+1 555 000 0000" + className={inputClassName} + /> +
+ + {validationError &&

{validationError}

} + {saveStatus === 'success' && ( +

{t('settings.saveSuccess')}

+ )} + {saveStatus === 'error' && ( +

{t('settings.saveError')}

+ )} + + +
+ )} +
+