diff --git a/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx b/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx new file mode 100644 index 0000000..6891c1a --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx @@ -0,0 +1,69 @@ +import { + SettingsFormCard, + type SettingFieldDef, +} from '@/components/admin/shared/settings-form-card'; + +const FIELDS: SettingFieldDef[] = [ + { + key: 'branding_app_name', + label: 'App name', + description: 'Shown in the email subject prefix and the in-app header.', + type: 'string', + placeholder: 'Port Nimara CRM', + defaultValue: '', + }, + { + key: 'branding_logo_url', + label: 'Logo URL', + description: + 'Public HTTPS URL of the logo used in email headers and the branded auth shell. Recommended size: 240×80 PNG with transparent background.', + type: 'string', + placeholder: 'https://example.com/logo.png', + defaultValue: '', + }, + { + key: 'branding_primary_color', + label: 'Primary color', + description: 'Used for buttons and links in transactional email templates.', + type: 'color', + defaultValue: '#1e293b', + }, + { + key: 'branding_email_header_html', + label: 'Email header HTML', + description: 'Optional HTML rendered above each email body. Leave blank to use the default.', + type: 'html', + defaultValue: '', + }, + { + key: 'branding_email_footer_html', + label: 'Email footer HTML', + description: 'Optional HTML rendered at the very bottom of each email (above the signature).', + type: 'html', + defaultValue: '', + }, +]; + +export default function BrandingSettingsPage() { + return ( +
+
+

Branding

+

+ Logo, primary color, app name, and email header/footer HTML used by the branded auth shell + and outgoing email templates. +

+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx b/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx new file mode 100644 index 0000000..21f0568 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx @@ -0,0 +1,73 @@ +import { + SettingsFormCard, + type SettingFieldDef, +} from '@/components/admin/shared/settings-form-card'; +import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button'; + +const API_FIELDS: SettingFieldDef[] = [ + { + key: 'documenso_api_url_override', + label: 'API URL override', + description: 'Optional. Falls back to DOCUMENSO_API_URL env when blank.', + type: 'string', + placeholder: 'https://documenso.example.com', + defaultValue: '', + }, + { + key: 'documenso_api_key_override', + label: 'API key override', + description: 'Optional. Falls back to DOCUMENSO_API_KEY env when blank. Stored in plain text.', + type: 'password', + defaultValue: '', + }, +]; + +const EOI_FIELDS: SettingFieldDef[] = [ + { + key: 'documenso_eoi_template_id', + label: 'EOI Documenso template ID', + description: 'Numeric template ID used by the Documenso EOI pathway.', + type: 'string', + placeholder: '12345', + defaultValue: '', + }, + { + key: 'eoi_default_pathway', + label: 'Default EOI pathway', + description: + 'Which pathway is used when an EOI is generated without an explicit choice. Documenso = signed via Documenso, In-app = filled locally with pdf-lib.', + type: 'select', + options: [ + { value: 'documenso-template', label: 'Documenso template' }, + { value: 'inapp', label: 'In-app (pdf-lib)' }, + ], + defaultValue: 'documenso-template', + }, +]; + +export default function DocumensoSettingsPage() { + return ( +
+
+

Documenso & EOI

+

+ API credentials and default EOI generation pathway. Use the test-connection button to + verify a saved configuration before relying on it. +

+
+ + } + /> + + +
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx new file mode 100644 index 0000000..371c5a4 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx @@ -0,0 +1,101 @@ +import { + SettingsFormCard, + type SettingFieldDef, +} from '@/components/admin/shared/settings-form-card'; + +const FIELDS: SettingFieldDef[] = [ + { + key: 'email_from_name', + label: 'From name', + description: 'Display name shown in the From: header on outgoing email.', + type: 'string', + placeholder: 'Port Nimara', + defaultValue: '', + }, + { + key: 'email_from_address', + label: 'From address', + description: 'Sender email address. Falls back to SMTP_FROM env when blank.', + type: 'string', + placeholder: 'noreply@example.com', + defaultValue: '', + }, + { + key: 'email_reply_to', + label: 'Reply-to address', + description: 'Optional Reply-To: header for replies (e.g. sales@example.com).', + type: 'string', + placeholder: 'sales@example.com', + defaultValue: '', + }, + { + key: 'email_signature_html', + label: 'Default signature (HTML)', + description: 'Appended to the bottom of system-generated emails.', + type: 'html', + placeholder: '


The Port Nimara team

', + defaultValue: '', + }, + { + key: 'email_footer_html', + label: 'Email footer (HTML)', + description: 'Legal/contact footer rendered at the very bottom of all emails.', + type: 'html', + placeholder: '

© Port Nimara · ul. ...

', + defaultValue: '', + }, + { + key: 'smtp_host_override', + label: 'SMTP host override', + description: 'Optional. Falls back to SMTP_HOST env when blank.', + type: 'string', + placeholder: 'mail.example.com', + defaultValue: '', + }, + { + key: 'smtp_port_override', + label: 'SMTP port override', + description: 'Optional. Falls back to SMTP_PORT env when blank.', + type: 'number', + placeholder: '587', + defaultValue: null, + }, + { + key: 'smtp_user_override', + label: 'SMTP username override', + description: 'Optional. Falls back to SMTP_USER env when blank.', + type: 'string', + defaultValue: '', + }, + { + key: 'smtp_pass_override', + label: 'SMTP password override', + description: 'Optional. Stored in plain text — only set when overriding env credentials.', + type: 'password', + defaultValue: '', + }, +]; + +export default function EmailSettingsPage() { + return ( +
+
+

Email Settings

+

+ Per-port outgoing email configuration. SMTP credentials and the From address default to + environment variables when these fields are blank. +

+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx b/src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx new file mode 100644 index 0000000..526a6ec --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx @@ -0,0 +1,16 @@ +import { InvitationsManager } from '@/components/admin/invitations/invitations-manager'; + +export default function InvitationsPage() { + return ( +
+
+

Invitations

+

+ Send a single-use invitation to a new CRM user. The recipient sets their own password via + the link in the email. +

+
+ +
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/admin/page.tsx b/src/app/(dashboard)/[portSlug]/admin/page.tsx new file mode 100644 index 0000000..f91014e --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/page.tsx @@ -0,0 +1,196 @@ +import Link from 'next/link'; +import { + Bell, + Briefcase, + Database, + FileText, + HardDrive, + Key, + LayoutDashboard, + Mail, + Palette, + ScrollText, + Settings, + Shield, + Sliders, + Tag, + Upload, + Users, + Webhook, +} from 'lucide-react'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +interface AdminSection { + href: string; + label: string; + description: string; + icon: typeof Settings; +} + +const SECTIONS: AdminSection[] = [ + { + href: 'users', + label: 'Users', + description: 'CRM accounts, role assignments, and per-user residential access toggles.', + icon: Users, + }, + { + href: 'invitations', + label: 'Invitations', + description: 'Send invitations, track pending invites, and resend or revoke them.', + icon: Mail, + }, + { + href: 'roles', + label: 'Roles & Permissions', + description: 'Default permission sets and per-port role overrides.', + icon: Shield, + }, + { + href: 'audit', + label: 'Audit Log', + description: 'Searchable log of every authenticated mutation in the system.', + icon: ScrollText, + }, + { + href: 'email', + label: 'Email Settings', + description: 'From address, signatures, and per-port SMTP overrides.', + icon: Mail, + }, + { + href: 'documenso', + label: 'Documenso & EOI', + description: 'API credentials, EOI template, and default in-app vs Documenso pathway.', + icon: FileText, + }, + { + href: 'reminders', + label: 'Reminders', + description: 'Default reminder behaviour and the daily-digest delivery window.', + icon: Bell, + }, + { + href: 'branding', + label: 'Branding', + description: 'App name, logo, primary color, and email header/footer HTML.', + icon: Palette, + }, + { + href: 'settings', + label: 'System Settings', + description: 'Generic key/value configuration store for advanced flags.', + icon: Settings, + }, + { + href: 'webhooks', + label: 'Webhooks', + description: 'Outgoing webhook subscriptions, secrets, and delivery log.', + icon: Webhook, + }, + { + href: 'forms', + label: 'Forms', + description: 'Form templates used by client-facing inquiry and intake flows.', + icon: Sliders, + }, + { + href: 'templates', + label: 'Document Templates', + description: 'PDF + email templates with merge-field placeholders.', + icon: FileText, + }, + { + href: 'tags', + label: 'Tags', + description: 'Color-coded tags applied to clients, yachts, companies, and interests.', + icon: Tag, + }, + { + href: 'custom-fields', + label: 'Custom Fields', + description: 'Tenant-defined fields for clients, yachts, and reservations.', + icon: Key, + }, + { + href: 'reports', + label: 'Reports', + description: 'Saved analytics views and ad-hoc query results.', + icon: LayoutDashboard, + }, + { + href: 'monitoring', + label: 'Queue Monitoring', + description: 'BullMQ queue health, throughput, and retry diagnostics.', + icon: Database, + }, + { + href: 'import', + label: 'Bulk Import', + description: 'CSV-driven imports for clients, yachts, and reservations.', + icon: Upload, + }, + { + href: 'backup', + label: 'Backup & Restore', + description: 'Database snapshots and on-demand exports.', + icon: HardDrive, + }, + { + href: 'ports', + label: 'Ports', + description: 'Manage the marinas/ports this installation serves.', + icon: Briefcase, + }, + { + href: 'onboarding', + label: 'Onboarding', + description: 'Initial-setup wizard for fresh ports.', + icon: LayoutDashboard, + }, +]; + +export default async function AdminLandingPage({ + params, +}: { + params: Promise<{ portSlug: string }>; +}) { + const { portSlug } = await params; + return ( +
+
+

Administration

+

+ Per-port configuration and system administration. Each card below opens a dedicated + settings page. +

+
+
+ {SECTIONS.map((s) => { + const Icon = s.icon; + return ( + + + + +
+ {s.label} +
+
+ + {s.description} + +
+ + ); + })} +
+
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx b/src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx new file mode 100644 index 0000000..019eb68 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx @@ -0,0 +1,78 @@ +import { + SettingsFormCard, + type SettingFieldDef, +} from '@/components/admin/shared/settings-form-card'; + +const DEFAULT_FIELDS: SettingFieldDef[] = [ + { + key: 'reminder_default_enabled', + label: 'Enable reminders by default on new interests', + description: + 'When on, newly-created interests inherit reminderEnabled=true. Users can still toggle it on a per-interest basis.', + type: 'boolean', + defaultValue: false, + }, + { + key: 'reminder_default_days', + label: 'Default inactivity days', + description: + "Default value for an interest's reminderDays field. Reminders fire after this many days of no contact.", + type: 'number', + placeholder: '7', + defaultValue: 7, + }, +]; + +const DIGEST_FIELDS: SettingFieldDef[] = [ + { + key: 'reminder_digest_enabled', + label: 'Batch reminders into a daily digest', + description: + 'Off (default): reminders fire as soon as the threshold is hit. On: pending reminders are accumulated and delivered once per day at the digest time.', + type: 'boolean', + defaultValue: false, + }, + { + key: 'reminder_digest_time', + label: 'Digest delivery time', + description: '24-hour HH:MM in the digest timezone.', + type: 'string', + placeholder: '09:00', + defaultValue: '09:00', + }, + { + key: 'reminder_digest_timezone', + label: 'Digest timezone', + description: 'IANA timezone name used to interpret the delivery time (e.g. Europe/Warsaw).', + type: 'string', + placeholder: 'Europe/Warsaw', + defaultValue: 'Europe/Warsaw', + }, +]; + +export default function ReminderSettingsPage() { + return ( +
+
+

Reminders

+

+ Default reminder behaviour for new interests and the optional daily-digest delivery + window. Individual users can still configure their own digest preferences in Notifications + → Preferences. +

+
+ + + + +
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/notifications/preferences/page.tsx b/src/app/(dashboard)/[portSlug]/notifications/preferences/page.tsx index 1556b93..df1fd13 100644 --- a/src/app/(dashboard)/[portSlug]/notifications/preferences/page.tsx +++ b/src/app/(dashboard)/[portSlug]/notifications/preferences/page.tsx @@ -1,15 +1,17 @@ import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form'; +import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form'; export default function NotificationPreferencesPage() { return ( -
-
+
+

Notification Preferences

Choose which notifications you receive and how.

+
); } diff --git a/src/app/api/v1/admin/documenso/health/route.ts b/src/app/api/v1/admin/documenso/health/route.ts new file mode 100644 index 0000000..f9f7218 --- /dev/null +++ b/src/app/api/v1/admin/documenso/health/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { checkDocumensoHealth } from '@/lib/services/documenso-client'; + +/** + * Admin probe — calls Documenso /api/v1/health using the port's effective + * config. Used by the "Test connection" button on /admin/documenso. + */ +export const POST = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx) => { + try { + const result = await checkDocumensoHealth(ctx.portId); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/invitations/[id]/resend/route.ts b/src/app/api/v1/admin/invitations/[id]/resend/route.ts new file mode 100644 index 0000000..cd22300 --- /dev/null +++ b/src/app/api/v1/admin/invitations/[id]/resend/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { resendCrmInvite } from '@/lib/services/crm-invite.service'; + +export const POST = withAuth( + withPermission('admin', 'manage_users', async (_req, ctx, params) => { + try { + const id = params.id ?? ''; + const result = await resendCrmInvite(id, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/invitations/[id]/route.ts b/src/app/api/v1/admin/invitations/[id]/route.ts new file mode 100644 index 0000000..e8aac2d --- /dev/null +++ b/src/app/api/v1/admin/invitations/[id]/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { revokeCrmInvite } from '@/lib/services/crm-invite.service'; + +export const DELETE = withAuth( + withPermission('admin', 'manage_users', async (_req, ctx, params) => { + try { + const id = params.id ?? ''; + await revokeCrmInvite(id, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ success: true }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/invitations/route.ts b/src/app/api/v1/admin/invitations/route.ts new file mode 100644 index 0000000..0d2eab5 --- /dev/null +++ b/src/app/api/v1/admin/invitations/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.service'; + +export const GET = withAuth( + withPermission('admin', 'manage_users', async (_req, _ctx) => { + try { + const data = await listCrmInvites(); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); + +const createInviteSchema = z.object({ + email: z.string().email(), + name: z.string().min(1).max(200).optional(), + isSuperAdmin: z.boolean().optional().default(false), +}); + +export const POST = withAuth( + withPermission('admin', 'manage_users', async (req, _ctx) => { + try { + const body = await parseBody(req, createInviteSchema); + const result = await createCrmInvite(body); + return NextResponse.json({ data: result }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/users/me/preferences/route.ts b/src/app/api/v1/users/me/preferences/route.ts new file mode 100644 index 0000000..6de3ff5 --- /dev/null +++ b/src/app/api/v1/users/me/preferences/route.ts @@ -0,0 +1,47 @@ +import { eq } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; + +import { withAuth } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { db } from '@/lib/db'; +import { userProfiles, type UserPreferences } from '@/lib/db/schema/users'; +import { errorResponse } from '@/lib/errors'; +import { updateUserPreferencesSchema } from '@/lib/validators/user-preferences'; + +export const GET = withAuth(async (_req, ctx) => { + try { + const profile = await db.query.userProfiles.findFirst({ + where: eq(userProfiles.userId, ctx.userId), + }); + return NextResponse.json({ data: profile?.preferences ?? {} }); + } catch (error) { + return errorResponse(error); + } +}); + +export const PATCH = withAuth(async (req, ctx) => { + try { + const patch = await parseBody(req, updateUserPreferencesSchema); + + const profile = await db.query.userProfiles.findFirst({ + where: eq(userProfiles.userId, ctx.userId), + }); + if (!profile) { + return NextResponse.json({ error: 'Profile not found' }, { status: 404 }); + } + + const next: UserPreferences = { + ...(profile.preferences ?? {}), + ...patch, + }; + + await db + .update(userProfiles) + .set({ preferences: next }) + .where(eq(userProfiles.userId, ctx.userId)); + + return NextResponse.json({ data: next }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/components/admin/documenso/documenso-test-button.tsx b/src/components/admin/documenso/documenso-test-button.tsx new file mode 100644 index 0000000..6248c57 --- /dev/null +++ b/src/components/admin/documenso/documenso-test-button.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useState } from 'react'; +import { Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { apiFetch } from '@/lib/api/client'; + +interface HealthResponse { + ok: boolean; + status?: number; + error?: string; +} + +export function DocumensoTestButton() { + const [pending, setPending] = useState(false); + const [result, setResult] = useState(null); + + async function runTest() { + setPending(true); + setResult(null); + try { + const res = await apiFetch<{ data: HealthResponse }>('/api/v1/admin/documenso/health', { + method: 'POST', + }); + setResult(res.data); + if (res.data.ok) { + toast.success(`Documenso reachable (HTTP ${res.data.status ?? 200})`); + } else { + toast.error( + res.data.error ?? `Documenso responded with HTTP ${res.data.status ?? 'unknown'}`, + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Test failed'; + setResult({ ok: false, error: message }); + toast.error(message); + } finally { + setPending(false); + } + } + + return ( +
+ {result && + (result.ok ? ( + + + HTTP {result.status} + + ) : ( + + + {result.error ?? `HTTP ${result.status}`} + + ))} + +
+ ); +} diff --git a/src/components/admin/invitations/invitations-manager.tsx b/src/components/admin/invitations/invitations-manager.tsx new file mode 100644 index 0000000..b94788c --- /dev/null +++ b/src/components/admin/invitations/invitations-manager.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Loader2, Mail, RotateCw, Plus, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; +import { apiFetch } from '@/lib/api/client'; + +interface Invite { + id: string; + email: string; + name: string | null; + isSuperAdmin: boolean; + expiresAt: string; + usedAt: string | null; + createdAt: string; + status: 'pending' | 'accepted' | 'expired'; +} + +const STATUS_STYLES: Record = { + pending: 'bg-amber-100 text-amber-800 border-amber-200', + accepted: 'bg-green-100 text-green-800 border-green-200', + expired: 'bg-muted text-muted-foreground border-muted', +}; + +export function InvitationsManager() { + const qc = useQueryClient(); + const [sheetOpen, setSheetOpen] = useState(false); + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [isSuperAdmin, setIsSuperAdmin] = useState(false); + + const { data: invites = [], isLoading } = useQuery({ + queryKey: ['admin', 'invitations'], + queryFn: () => apiFetch<{ data: Invite[] }>('/api/v1/admin/invitations').then((r) => r.data), + }); + + const createMutation = useMutation({ + mutationFn: () => + apiFetch('/api/v1/admin/invitations', { + method: 'POST', + body: { email, name: name || undefined, isSuperAdmin }, + }), + onSuccess: () => { + toast.success(`Invite sent to ${email}`); + setSheetOpen(false); + setEmail(''); + setName(''); + setIsSuperAdmin(false); + qc.invalidateQueries({ queryKey: ['admin', 'invitations'] }); + }, + onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to send invite'), + }); + + const resendMutation = useMutation({ + mutationFn: (id: string) => + apiFetch(`/api/v1/admin/invitations/${id}/resend`, { method: 'POST' }), + onSuccess: () => { + toast.success('Invite resent'); + qc.invalidateQueries({ queryKey: ['admin', 'invitations'] }); + }, + onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to resend'), + }); + + const revokeMutation = useMutation({ + mutationFn: (id: string) => apiFetch(`/api/v1/admin/invitations/${id}`, { method: 'DELETE' }), + onSuccess: () => { + toast.success('Invite revoked'); + qc.invalidateQueries({ queryKey: ['admin', 'invitations'] }); + }, + onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to revoke'), + }); + + return ( +
+
+
+

Pending invitations

+

+ Invitations expire 72 hours after issue. Resending mints a new token and emails it. +

+
+ +
+ + {isLoading ? ( +

Loading…

+ ) : invites.length === 0 ? ( +
+ +

No invitations issued yet.

+
+ ) : ( +
+ + + + + + + + + + + + + {invites.map((i) => ( + + + + + + + + + ))} + +
EmailNameRoleStatusExpires
{i.email}{i.name ?? '—'} + {i.isSuperAdmin ? 'Super admin' : 'Standard user'} + + + {i.status} + + + {new Date(i.expiresAt).toLocaleString()} + + {i.status === 'pending' || i.status === 'expired' ? ( +
+ + {i.status === 'pending' && ( + + + + } + title="Revoke invitation?" + description={`Revoke the pending invitation for ${i.email}? The link in the email will stop working.`} + confirmLabel="Revoke" + onConfirm={() => revokeMutation.mutate(i.id)} + /> + )} +
+ ) : ( + + )} +
+
+ )} + + + + + Send invitation + +
{ + e.preventDefault(); + createMutation.mutate(); + }} + > +
+ + setEmail(e.target.value)} + required + /> +
+
+ + setName(e.target.value)} + placeholder="Optional" + /> +
+
+
+ +

+ Super admins bypass per-port permission checks. Use sparingly. +

+
+ +
+ + + + +
+
+
+
+ ); +} diff --git a/src/components/admin/shared/settings-form-card.tsx b/src/components/admin/shared/settings-form-card.tsx new file mode 100644 index 0000000..b8fe171 --- /dev/null +++ b/src/components/admin/shared/settings-form-card.tsx @@ -0,0 +1,309 @@ +'use client'; + +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { apiFetch } from '@/lib/api/client'; + +export type SettingFieldType = + | 'string' + | 'password' + | 'number' + | 'boolean' + | 'textarea' + | 'html' + | 'select' + | 'color'; + +export interface SettingFieldDef { + key: string; + label: string; + description?: string; + type: SettingFieldType; + placeholder?: string; + defaultValue: unknown; + options?: Array<{ value: string; label: string }>; +} + +interface SettingsRowResponse { + key: string; + value: unknown; + portId: string | null; +} + +interface ListResponse { + data: { portSettings: SettingsRowResponse[]; globalSettings: SettingsRowResponse[] }; +} + +interface SettingsFormCardProps { + title: string; + description?: string; + fields: SettingFieldDef[]; + /** Optional extra slot rendered below the form (e.g. test-connection button). */ + extra?: ReactNode; +} + +/** + * Reusable card that fetches /api/v1/admin/settings, renders editable form + * fields for the supplied keys, and PUTs each on save. + */ +export function SettingsFormCard({ title, description, fields, extra }: SettingsFormCardProps) { + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [values, setValues] = useState>({}); + const [originals, setOriginals] = useState>({}); + + const fetchValues = useCallback(async () => { + setLoading(true); + try { + const res = await apiFetch('/api/v1/admin/settings'); + const next: Record = {}; + for (const field of fields) { + const port = res.data.portSettings.find((s) => s.key === field.key); + const global = res.data.globalSettings.find((s) => s.key === field.key); + next[field.key] = port?.value ?? global?.value ?? field.defaultValue; + } + setValues(next); + setOriginals(next); + } finally { + setLoading(false); + } + }, [fields]); + + useEffect(() => { + void fetchValues(); + }, [fetchValues]); + + function setField(key: string, value: unknown) { + setValues((prev) => ({ ...prev, [key]: value })); + } + + async function handleSave() { + setSaving(true); + try { + const changedFields = fields.filter((f) => values[f.key] !== originals[f.key]); + if (changedFields.length === 0) { + toast.info('No changes to save'); + return; + } + for (const f of changedFields) { + await apiFetch('/api/v1/admin/settings', { + method: 'PUT', + body: { key: f.key, value: values[f.key] }, + }); + } + toast.success(`Saved ${changedFields.length} setting(s)`); + setOriginals(values); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Save failed'); + } finally { + setSaving(false); + } + } + + if (loading) { + return ( + + + {title} + + +
+ + Loading… +
+
+
+ ); + } + + const dirty = fields.some((f) => values[f.key] !== originals[f.key]); + + return ( + + + {title} + {description && {description}} + + + {fields.map((field) => ( + setField(field.key, v)} + /> + ))} + +
+ {extra} + +
+
+
+ ); +} + +function FieldRow({ + field, + value, + onChange, +}: { + field: SettingFieldDef; + value: unknown; + onChange: (v: unknown) => void; +}) { + const id = `setting-${field.key}`; + + switch (field.type) { + case 'boolean': + return ( +
+
+ + {field.description && ( +

{field.description}

+ )} +
+ onChange(checked)} /> +
+ ); + + case 'textarea': + case 'html': + return ( +
+ + {field.description && ( +

{field.description}

+ )} +