From cf192cdfb2310981cc15ffb53047e5778fb09d12 Mon Sep 17 00:00:00 2001 From: Faithy5 Date: Tue, 18 Aug 2026 15:11:01 +0000 Subject: [PATCH] feat(settings): add org profile management to Settings page Extend the Settings page beyond the language switcher with an Organization Profile section where org admins can view and edit basic org details (name, contact email, contact phone). Adds GET/PUT /api/v1/organizations/profile endpoints backed by the organizations table (with a migration for the new contact columns), a frontend orgApi service, and en/es i18n strings. --- .../db/migrations/029_org_profile_fields.sql | 6 + backend/src/routes/organizationRoutes.ts | 114 +++++++++++++ backend/src/routes/v1/index.ts | 2 + frontend/src/locales/en/translation.json | 14 ++ frontend/src/locales/es/translation.json | 14 ++ frontend/src/pages/Settings.tsx | 150 ++++++++++++++++++ frontend/src/services/orgApi.ts | 49 ++++++ 7 files changed, 349 insertions(+) create mode 100644 backend/src/db/migrations/029_org_profile_fields.sql create mode 100644 backend/src/routes/organizationRoutes.ts create mode 100644 frontend/src/services/orgApi.ts 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 9aa3dd86..5269d04e 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 908f8669..3e08807c 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 5921a401..465af2c8 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,4 +1,8 @@ import { useTranslation } from 'react-i18next'; +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(); @@ -7,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 (
@@ -15,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')}

+ )} + + +
+ )} +
+