Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/src/db/migrations/029_org_profile_fields.sql
Original file line number Diff line number Diff line change
@@ -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);
114 changes: 114 additions & 0 deletions backend/src/routes/organizationRoutes.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions backend/src/routes/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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;
14 changes: 14 additions & 0 deletions frontend/src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
152 changes: 150 additions & 2 deletions frontend/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -9,6 +11,67 @@ export default function Settings() {
void i18n.changeLanguage(event.target.value);
};

const [profile, setProfile] = useState<OrgProfile | null>(null);
const [form, setForm] = useState({ name: '', contactEmail: '', contactPhone: '' });
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const [validationError, setValidationError] = useState<string | null>(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 (
<div className="flex-1 flex flex-col items-center justify-start p-12 max-w-3xl mx-auto w-full">
<div className="w-full mb-12 flex items-end justify-between border-b border-hi pb-8">
Expand All @@ -17,6 +80,91 @@ export default function Settings() {
</div>
</div>

<div className="w-full card glass noise p-8 mb-8">
<div className="mb-6">
<h2 className="text-xl font-bold tracking-tight mb-1">
{t('settings.organizationSectionTitle')}
</h2>
<p className="text-sm text-muted">{t('settings.organizationSectionDescription')}</p>
</div>

{loading ? (
<p className="text-sm text-muted">{t('settings.loading')}</p>
) : loadError ? (
<div className="flex flex-col gap-3">
<p className="text-sm text-red-400">{t('settings.loadError')}</p>
<button
onClick={() => void loadProfile()}
className="self-start px-4 py-2 text-xs font-bold uppercase tracking-widest rounded-xl border border-hi text-text hover:border-accent/50 hover:bg-accent/5 transition-all"
>
{t('settings.retry')}
</button>
</div>
) : (
<div className="flex flex-col gap-5">
{profile && (
<p className="text-xs font-mono text-muted">
{t('settings.organizationIdLabel')}: {profile.id}
</p>
)}

<div className="flex flex-col gap-3">
<label className="block text-xs font-bold uppercase tracking-widest text-muted">
{t('settings.orgNameLabel')}
</label>
<input
type="text"
value={form.name}
onChange={(event) => handleFieldChange('name', event.target.value)}
className={inputClassName}
/>
</div>

<div className="flex flex-col gap-3">
<label className="block text-xs font-bold uppercase tracking-widest text-muted">
{t('settings.orgContactEmailLabel')}
</label>
<input
type="email"
value={form.contactEmail}
onChange={(event) => handleFieldChange('contactEmail', event.target.value)}
placeholder="admin@example.com"
className={inputClassName}
/>
</div>

<div className="flex flex-col gap-3">
<label className="block text-xs font-bold uppercase tracking-widest text-muted">
{t('settings.orgContactPhoneLabel')}
</label>
<input
type="tel"
value={form.contactPhone}
onChange={(event) => handleFieldChange('contactPhone', event.target.value)}
placeholder="+1 555 000 0000"
className={inputClassName}
/>
</div>

{validationError && <p className="text-sm text-red-400">{validationError}</p>}
{saveStatus === 'success' && (
<p className="text-sm text-emerald-400">{t('settings.saveSuccess')}</p>
)}
{saveStatus === 'error' && (
<p className="text-sm text-red-400">{t('settings.saveError')}</p>
)}

<button
onClick={() => void handleSave()}
disabled={saveStatus === 'saving'}
className="self-start px-6 py-3 bg-accent text-black font-bold rounded-xl hover:opacity-90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{saveStatus === 'saving' ? t('settings.saving') : t('settings.save')}
</button>
</div>
)}
</div>

<div className="w-full card glass noise p-8">
<div className="flex flex-col gap-3">
<label className="block text-xs font-bold uppercase tracking-widest text-muted">
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/services/orgApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import axios from 'axios';

const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api/v1';

export interface OrgProfile {
id: number;
name: string;
publicKey: string | null;
contactEmail: string | null;
contactPhone: string | null;
isActive: boolean;
subscriptionTier: string;
createdAt: string | null;
updatedAt: string | null;
}

export interface UpdateOrgProfileInput {
name?: string;
contactEmail?: string;
contactPhone?: string;
}

function authHeaders() {
const token = localStorage.getItem('payd_auth_token');
return token ? { Authorization: `Bearer ${token}` } : undefined;
}

export const getOrgProfile = async (): Promise<OrgProfile> => {
const { data } = await axios.get<{ organization: OrgProfile }>(
`${API_BASE_URL}/organizations/profile`,
{
headers: authHeaders(),
}
);

return data.organization;
};

export const updateOrgProfile = async (input: UpdateOrgProfileInput): Promise<OrgProfile> => {
const { data } = await axios.put<{ organization: OrgProfile }>(
`${API_BASE_URL}/organizations/profile`,
input,
{
headers: authHeaders(),
}
);

return data.organization;
};