+
+
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}`}
+
+ ))}
+
+ {pending && }
+ Test connection
+
+
+ );
+}
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.
+
+
+
setSheetOpen(true)}>
+
+ Send invite
+
+
+
+ {isLoading ? (
+
Loading…
+ ) : invites.length === 0 ? (
+
+
+
No invitations issued yet.
+
+ ) : (
+
+
+
+
+ Email
+ Name
+ Role
+ Status
+ Expires
+
+
+
+
+ {invites.map((i) => (
+
+ {i.email}
+ {i.name ?? '—'}
+
+ {i.isSuperAdmin ? 'Super admin' : 'Standard user'}
+
+
+
+ {i.status}
+
+
+
+ {new Date(i.expiresAt).toLocaleString()}
+
+
+ {i.status === 'pending' || i.status === 'expired' ? (
+
+ resendMutation.mutate(i.id)}
+ disabled={resendMutation.isPending || !!i.usedAt}
+ title="Resend invite"
+ >
+
+
+ {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
+
+
+
+
+
+ );
+}
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}
+
+ {saving && }
+ Save changes
+
+
+
+
+ );
+}
+
+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.label}
+ {field.description && (
+
{field.description}
+ )}
+
+
onChange(checked)} />
+
+ );
+
+ case 'textarea':
+ case 'html':
+ return (
+
+
{field.label}
+ {field.description && (
+
{field.description}
+ )}
+
+ );
+
+ case 'select':
+ return (
+
+
{field.label}
+ {field.description && (
+
{field.description}
+ )}
+
onChange(v)}>
+
+
+
+
+ {(field.options ?? []).map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+
+ );
+
+ case 'number':
+ return (
+
+
{field.label}
+ {field.description && (
+
{field.description}
+ )}
+
{
+ const n = e.target.value === '' ? null : Number(e.target.value);
+ onChange(n);
+ }}
+ />
+
+ );
+
+ case 'color':
+ return (
+
+
{field.label}
+ {field.description && (
+
{field.description}
+ )}
+
+ onChange(e.target.value)}
+ className="h-9 w-14 cursor-pointer rounded border"
+ />
+ onChange(e.target.value)}
+ placeholder="#000000"
+ className="font-mono"
+ />
+
+
+ );
+
+ case 'password':
+ return (
+
+
{field.label}
+ {field.description && (
+
{field.description}
+ )}
+
onChange(e.target.value)}
+ />
+
+ );
+
+ case 'string':
+ default:
+ return (
+
+
{field.label}
+ {field.description && (
+
{field.description}
+ )}
+
onChange(e.target.value)}
+ />
+
+ );
+ }
+}
diff --git a/src/components/notifications/reminder-digest-form.tsx b/src/components/notifications/reminder-digest-form.tsx
new file mode 100644
index 0000000..9a746f8
--- /dev/null
+++ b/src/components/notifications/reminder-digest-form.tsx
@@ -0,0 +1,173 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+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 {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { apiFetch } from '@/lib/api/client';
+
+interface ReminderPrefs {
+ delivery: 'immediate' | 'daily' | 'weekly' | 'off';
+ digestTime?: string;
+ digestDayOfWeek?: number;
+ timezone?: string;
+}
+
+interface UserPrefsResponse {
+ reminders?: ReminderPrefs;
+ timezone?: string;
+}
+
+const DAYS = [
+ { value: '0', label: 'Sunday' },
+ { value: '1', label: 'Monday' },
+ { value: '2', label: 'Tuesday' },
+ { value: '3', label: 'Wednesday' },
+ { value: '4', label: 'Thursday' },
+ { value: '5', label: 'Friday' },
+ { value: '6', label: 'Saturday' },
+];
+
+export function ReminderDigestForm() {
+ const qc = useQueryClient();
+ const { data, isLoading } = useQuery({
+ queryKey: ['user', 'preferences'],
+ queryFn: () =>
+ apiFetch<{ data: UserPrefsResponse }>('/api/v1/users/me/preferences').then((r) => r.data),
+ });
+
+ const [delivery, setDelivery] = useState('immediate');
+ const [digestTime, setDigestTime] = useState('09:00');
+ const [digestDay, setDigestDay] = useState('1');
+ const [timezone, setTimezone] = useState('Europe/Warsaw');
+
+ useEffect(() => {
+ const r = data?.reminders;
+ if (r) {
+ setDelivery(r.delivery ?? 'immediate');
+ setDigestTime(r.digestTime ?? '09:00');
+ setDigestDay(String(r.digestDayOfWeek ?? 1));
+ setTimezone(r.timezone ?? data?.timezone ?? 'Europe/Warsaw');
+ } else if (data?.timezone) {
+ setTimezone(data.timezone);
+ }
+ }, [data]);
+
+ const mutation = useMutation({
+ mutationFn: () =>
+ apiFetch('/api/v1/users/me/preferences', {
+ method: 'PATCH',
+ body: {
+ reminders: {
+ delivery,
+ ...(delivery !== 'immediate' && delivery !== 'off' ? { digestTime, timezone } : {}),
+ ...(delivery === 'weekly' ? { digestDayOfWeek: Number(digestDay) } : {}),
+ },
+ },
+ }),
+ onSuccess: () => {
+ toast.success('Reminder preferences saved');
+ qc.invalidateQueries({ queryKey: ['user', 'preferences'] });
+ },
+ onError: (err) => toast.error(err instanceof Error ? err.message : 'Save failed'),
+ });
+
+ if (isLoading) {
+ return (
+
+
+
+ Loading…
+
+
+ );
+ }
+
+ return (
+
+
+ Reminder digest
+
+ Choose how reminder notifications are delivered to you. Immediate fires as soon as a
+ reminder triggers; daily/weekly batch them into a digest at the time you pick.
+
+
+
+
+ Delivery
+ setDelivery(v as ReminderPrefs['delivery'])}
+ >
+
+
+
+
+ Immediate (default)
+ Daily digest
+ Weekly digest
+ Off (do not send)
+
+
+
+
+ {(delivery === 'daily' || delivery === 'weekly') && (
+
+
+ Delivery time (HH:MM)
+ setDigestTime(e.target.value)}
+ placeholder="09:00"
+ />
+
+ {delivery === 'weekly' && (
+
+ Day of week
+
+
+
+
+
+ {DAYS.map((d) => (
+
+ {d.label}
+
+ ))}
+
+
+
+ )}
+
+ Timezone (IANA)
+ setTimezone(e.target.value)}
+ placeholder="Europe/Warsaw"
+ />
+
+
+ )}
+
+
+ mutation.mutate()} disabled={mutation.isPending}>
+ {mutation.isPending && }
+ Save digest preferences
+
+
+
+
+ );
+}
diff --git a/src/lib/audit.ts b/src/lib/audit.ts
index b7f6bc6..4ebe579 100644
--- a/src/lib/audit.ts
+++ b/src/lib/audit.ts
@@ -12,7 +12,9 @@ export type AuditAction =
| 'login'
| 'logout'
| 'permission_denied'
- | 'revert';
+ | 'revert'
+ | 'revoke_invite'
+ | 'resend_invite';
export interface AuditLogParams {
/** Null for system-generated events. */
@@ -30,13 +32,7 @@ export interface AuditLogParams {
userAgent: string;
}
-const SENSITIVE_FIELDS = new Set([
- 'email',
- 'phone',
- 'password',
- 'credentials_enc',
- 'token',
-]);
+const SENSITIVE_FIELDS = new Set(['email', 'phone', 'password', 'credentials_enc', 'token']);
/**
* Masks sensitive field values to prevent PII or secrets from being stored
diff --git a/src/lib/email/index.ts b/src/lib/email/index.ts
index 74b1370..b9a2175 100644
--- a/src/lib/email/index.ts
+++ b/src/lib/email/index.ts
@@ -2,9 +2,11 @@ import nodemailer, { type Transporter } from 'nodemailer';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
+import { getPortEmailConfig, type PortEmailConfig } from '@/lib/services/port-config';
/**
- * Creates and returns a new Nodemailer SMTP transporter.
+ * Creates and returns a new Nodemailer SMTP transporter using env defaults.
+ * For port-scoped configuration use {@link createPortTransporter} instead.
*
* A new instance is created on each call so the factory can be used in
* contexts where connection pooling is managed externally (e.g. per-request
@@ -22,11 +24,23 @@ export function createTransporter(): Transporter {
});
}
+function createTransporterFromConfig(cfg: PortEmailConfig): Transporter {
+ return nodemailer.createTransport({
+ host: cfg.smtpHost,
+ port: cfg.smtpPort,
+ secure: cfg.smtpPort === 465,
+ ...(cfg.smtpUser && cfg.smtpPass ? { auth: { user: cfg.smtpUser, pass: cfg.smtpPass } } : {}),
+ });
+}
+
export interface SendEmailOptions {
to: string | string[];
subject: string;
html: string;
from?: string;
+ /** When provided, port-level email settings override env defaults. */
+ portId?: string;
+ text?: string;
}
/**
@@ -42,8 +56,10 @@ export async function sendEmail(
html: string,
from?: string,
text?: string,
+ portId?: string,
): Promise {
- const transporter = createTransporter();
+ const cfg = portId ? await getPortEmailConfig(portId) : null;
+ const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter();
const requestedTo = Array.isArray(to) ? to.join(', ') : to;
const effectiveTo = env.EMAIL_REDIRECT_TO ?? requestedTo;
@@ -51,16 +67,23 @@ export async function sendEmail(
? `[redirected from ${requestedTo}] ${subject}`
: subject;
+ const fromHeader =
+ from ??
+ (cfg ? `${cfg.fromName} <${cfg.fromAddress}>` : undefined) ??
+ env.SMTP_FROM ??
+ `Port Nimara CRM `;
+
const info = await transporter.sendMail({
- from: from ?? env.SMTP_FROM ?? `Port Nimara CRM `,
+ from: fromHeader,
to: effectiveTo,
subject: effectiveSubject,
html,
+ ...(cfg?.replyTo ? { replyTo: cfg.replyTo } : {}),
...(text ? { text } : {}),
});
logger.debug(
- { messageId: info.messageId, to: effectiveTo, originalTo: requestedTo, subject },
+ { messageId: info.messageId, to: effectiveTo, originalTo: requestedTo, subject, portId },
env.EMAIL_REDIRECT_TO ? 'Email sent (redirected)' : 'Email sent',
);
diff --git a/src/lib/services/crm-invite.service.ts b/src/lib/services/crm-invite.service.ts
index a19930e..86203d4 100644
--- a/src/lib/services/crm-invite.service.ts
+++ b/src/lib/services/crm-invite.service.ts
@@ -1,7 +1,8 @@
-import { and, eq, gt, isNull } from 'drizzle-orm';
+import { and, desc, eq, gt, isNull } from 'drizzle-orm';
import postgres from 'postgres';
import { auth } from '@/lib/auth';
+import { createAuditLog } from '@/lib/audit';
import { db } from '@/lib/db';
import { crmUserInvites } from '@/lib/db/schema/crm-invites';
import { userProfiles } from '@/lib/db/schema/users';
@@ -11,6 +12,13 @@ import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { hashToken, mintToken } from '@/lib/portal/passwords';
+interface AuditMeta {
+ userId: string;
+ portId: string;
+ ipAddress: string;
+ userAgent: string;
+}
+
const INVITE_TTL_HOURS = 72;
const MIN_PASSWORD_LENGTH = 9;
@@ -116,3 +124,111 @@ export async function consumeCrmInvite(args: {
return { userId, email: invite.email };
}
+
+// ─── Admin operations ────────────────────────────────────────────────────────
+
+export interface InviteRow {
+ id: string;
+ email: string;
+ name: string | null;
+ isSuperAdmin: boolean;
+ expiresAt: Date;
+ usedAt: Date | null;
+ createdAt: Date;
+ status: 'pending' | 'accepted' | 'expired';
+}
+
+export async function listCrmInvites(): Promise {
+ const rows = await db
+ .select({
+ id: crmUserInvites.id,
+ email: crmUserInvites.email,
+ name: crmUserInvites.name,
+ isSuperAdmin: crmUserInvites.isSuperAdmin,
+ expiresAt: crmUserInvites.expiresAt,
+ usedAt: crmUserInvites.usedAt,
+ createdAt: crmUserInvites.createdAt,
+ })
+ .from(crmUserInvites)
+ .orderBy(desc(crmUserInvites.createdAt))
+ .limit(200);
+
+ const now = Date.now();
+ return rows.map((r) => {
+ let status: InviteRow['status'];
+ if (r.usedAt) status = 'accepted';
+ else if (r.expiresAt.getTime() < now) status = 'expired';
+ else status = 'pending';
+ return { ...r, status };
+ });
+}
+
+export async function revokeCrmInvite(inviteId: string, meta: AuditMeta): Promise {
+ const invite = await db.query.crmUserInvites.findFirst({
+ where: eq(crmUserInvites.id, inviteId),
+ });
+ if (!invite) throw new NotFoundError('Invite');
+ if (invite.usedAt) throw new ConflictError('Invite already accepted — cannot revoke');
+
+ // Force expiration; tokenHash stays in place so any in-flight click fails
+ // the `expiresAt > now` check at consume time.
+ await db
+ .update(crmUserInvites)
+ .set({ expiresAt: new Date(0) })
+ .where(eq(crmUserInvites.id, inviteId));
+
+ void createAuditLog({
+ userId: meta.userId,
+ portId: meta.portId,
+ action: 'revoke_invite',
+ entityType: 'crm_invite',
+ entityId: inviteId,
+ metadata: { email: invite.email },
+ ipAddress: meta.ipAddress,
+ userAgent: meta.userAgent,
+ });
+}
+
+export async function resendCrmInvite(
+ inviteId: string,
+ meta: AuditMeta,
+): Promise<{ link: string }> {
+ const invite = await db.query.crmUserInvites.findFirst({
+ where: eq(crmUserInvites.id, inviteId),
+ });
+ if (!invite) throw new NotFoundError('Invite');
+ if (invite.usedAt) throw new ConflictError('Invite already accepted — nothing to resend');
+
+ // Mint a fresh token + push expiry forward so the resent link is the only
+ // working one. The old token hash is overwritten so prior emails become
+ // dead links.
+ const { raw, hash } = mintToken();
+ const expiresAt = new Date(Date.now() + INVITE_TTL_HOURS * 3600 * 1000);
+
+ await db
+ .update(crmUserInvites)
+ .set({ tokenHash: hash, expiresAt })
+ .where(eq(crmUserInvites.id, inviteId));
+
+ const link = `${env.APP_URL}/set-password?token=${raw}`;
+ const { subject, html, text } = crmInviteEmail({
+ link,
+ ttlHours: INVITE_TTL_HOURS,
+ recipientName: invite.name ?? undefined,
+ isSuperAdmin: invite.isSuperAdmin,
+ });
+ await sendEmail(invite.email, subject, html, undefined, text);
+
+ void createAuditLog({
+ userId: meta.userId,
+ portId: meta.portId,
+ action: 'resend_invite',
+ entityType: 'crm_invite',
+ entityId: inviteId,
+ metadata: { email: invite.email },
+ ipAddress: meta.ipAddress,
+ userAgent: meta.userAgent,
+ });
+
+ return { link };
+}
diff --git a/src/lib/services/documenso-client.ts b/src/lib/services/documenso-client.ts
index 595a776..2f8ff0a 100644
--- a/src/lib/services/documenso-client.ts
+++ b/src/lib/services/documenso-client.ts
@@ -1,14 +1,28 @@
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
+import { getPortDocumensoConfig } from '@/lib/services/port-config';
-const BASE_URL = env.DOCUMENSO_API_URL;
-const API_KEY = env.DOCUMENSO_API_KEY;
+interface DocumensoCreds {
+ baseUrl: string;
+ apiKey: string;
+}
-async function documensoFetch(path: string, options?: RequestInit): Promise {
- const res = await fetch(`${BASE_URL}${path}`, {
+async function resolveCreds(portId?: string): Promise {
+ if (!portId) return { baseUrl: env.DOCUMENSO_API_URL, apiKey: env.DOCUMENSO_API_KEY };
+ const cfg = await getPortDocumensoConfig(portId);
+ return { baseUrl: cfg.apiUrl, apiKey: cfg.apiKey };
+}
+
+async function documensoFetch(
+ path: string,
+ options?: RequestInit,
+ portId?: string,
+): Promise {
+ const { baseUrl, apiKey } = await resolveCreds(portId);
+ const res = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
- Authorization: `Bearer ${API_KEY}`,
+ Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...options?.headers,
},
@@ -16,7 +30,7 @@ async function documensoFetch(path: string, options?: RequestInit): Promise {
- return documensoFetch('/api/v1/documents', {
- method: 'POST',
- body: JSON.stringify({ title, document: pdfBase64, recipients }),
- }).then(normalizeDocument);
+ return documensoFetch(
+ '/api/v1/documents',
+ {
+ method: 'POST',
+ body: JSON.stringify({ title, document: pdfBase64, recipients }),
+ },
+ portId,
+ ).then(normalizeDocument);
}
export async function generateDocumentFromTemplate(
templateId: number,
payload: Record,
+ portId?: string,
): Promise {
- return documensoFetch(`/api/v1/templates/${templateId}/generate-document`, {
- method: 'POST',
- body: JSON.stringify(payload),
- }).then(normalizeDocument);
+ return documensoFetch(
+ `/api/v1/templates/${templateId}/generate-document`,
+ {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ },
+ portId,
+ ).then(normalizeDocument);
}
-export async function sendDocument(docId: string): Promise {
- return documensoFetch(`/api/v1/documents/${docId}/send`, {
- method: 'POST',
- }).then(normalizeDocument);
+export async function sendDocument(docId: string, portId?: string): Promise {
+ return documensoFetch(
+ `/api/v1/documents/${docId}/send`,
+ {
+ method: 'POST',
+ },
+ portId,
+ ).then(normalizeDocument);
}
-export async function getDocument(docId: string): Promise {
- return documensoFetch(`/api/v1/documents/${docId}`).then(normalizeDocument);
+export async function getDocument(docId: string, portId?: string): Promise {
+ return documensoFetch(`/api/v1/documents/${docId}`, undefined, portId).then(normalizeDocument);
}
-export async function sendReminder(docId: string, signerId: string): Promise {
- await documensoFetch(`/api/v1/documents/${docId}/recipients/${signerId}/remind`, {
- method: 'POST',
- });
+export async function sendReminder(
+ docId: string,
+ signerId: string,
+ portId?: string,
+): Promise {
+ await documensoFetch(
+ `/api/v1/documents/${docId}/recipients/${signerId}/remind`,
+ {
+ method: 'POST',
+ },
+ portId,
+ );
}
-export async function downloadSignedPdf(docId: string): Promise {
- const res = await fetch(`${BASE_URL}/api/v1/documents/${docId}/download`, {
- headers: { Authorization: `Bearer ${API_KEY}` },
+export async function downloadSignedPdf(docId: string, portId?: string): Promise {
+ const { baseUrl, apiKey } = await resolveCreds(portId);
+ const res = await fetch(`${baseUrl}/api/v1/documents/${docId}/download`, {
+ headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
const err = await res.text();
- logger.error({ docId, status: res.status, err }, 'Documenso download error');
+ logger.error({ docId, status: res.status, err, portId }, 'Documenso download error');
throw new Error(`Documenso download error: ${res.status}`);
}
const arrayBuffer = await res.arrayBuffer();
return Buffer.from(arrayBuffer);
}
+
+/** Convenience health-check used by the admin "Test connection" button. */
+export async function checkDocumensoHealth(
+ portId?: string,
+): Promise<{ ok: boolean; status?: number; error?: string }> {
+ try {
+ const { baseUrl, apiKey } = await resolveCreds(portId);
+ const res = await fetch(`${baseUrl}/api/v1/health`, {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ });
+ return { ok: res.ok, status: res.status };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' };
+ }
+}
diff --git a/src/lib/services/port-config.ts b/src/lib/services/port-config.ts
new file mode 100644
index 0000000..a4f774b
--- /dev/null
+++ b/src/lib/services/port-config.ts
@@ -0,0 +1,217 @@
+/**
+ * Typed accessors for port-level configuration with env-fallback.
+ *
+ * Settings are stored in the `system_settings` table keyed by (key, portId).
+ * The functions in this module resolve a port's effective configuration for
+ * a given domain (email, Documenso, branding, reminders) by reading the
+ * port-scoped row first, falling back to the global row, and finally to the
+ * env var when neither is set.
+ */
+import { env } from '@/lib/env';
+import { getSetting } from '@/lib/services/settings.service';
+
+// ─── Setting key constants ───────────────────────────────────────────────────
+
+export const SETTING_KEYS = {
+ // Email
+ emailFromName: 'email_from_name',
+ emailFromAddress: 'email_from_address',
+ emailReplyTo: 'email_reply_to',
+ emailSignatureHtml: 'email_signature_html',
+ emailFooterHtml: 'email_footer_html',
+ smtpHostOverride: 'smtp_host_override',
+ smtpPortOverride: 'smtp_port_override',
+ smtpUserOverride: 'smtp_user_override',
+ smtpPassOverride: 'smtp_pass_override',
+
+ // Documenso / EOI
+ documensoApiUrlOverride: 'documenso_api_url_override',
+ documensoApiKeyOverride: 'documenso_api_key_override',
+ documensoEoiTemplateId: 'documenso_eoi_template_id',
+ eoiDefaultPathway: 'eoi_default_pathway',
+
+ // Branding
+ brandingLogoUrl: 'branding_logo_url',
+ brandingPrimaryColor: 'branding_primary_color',
+ brandingAppName: 'branding_app_name',
+ brandingEmailHeaderHtml: 'branding_email_header_html',
+ brandingEmailFooterHtml: 'branding_email_footer_html',
+
+ // Reminders (port-level defaults)
+ reminderDefaultDays: 'reminder_default_days',
+ reminderDefaultEnabled: 'reminder_default_enabled',
+ reminderDigestEnabled: 'reminder_digest_enabled',
+ reminderDigestTime: 'reminder_digest_time',
+ reminderDigestTimezone: 'reminder_digest_timezone',
+} as const;
+
+// ─── Helper ──────────────────────────────────────────────────────────────────
+
+async function readSetting(key: string, portId: string): Promise {
+ const setting = await getSetting(key, portId);
+ if (!setting) return null;
+ return setting.value as T;
+}
+
+// ─── Email ──────────────────────────────────────────────────────────────────
+
+export interface PortEmailConfig {
+ fromName: string;
+ fromAddress: string;
+ replyTo: string | null;
+ signatureHtml: string | null;
+ footerHtml: string | null;
+ smtpHost: string;
+ smtpPort: number;
+ smtpUser: string | null;
+ smtpPass: string | null;
+}
+
+export async function getPortEmailConfig(portId: string): Promise {
+ const [
+ fromName,
+ fromAddress,
+ replyTo,
+ signatureHtml,
+ footerHtml,
+ smtpHost,
+ smtpPort,
+ smtpUser,
+ smtpPass,
+ ] = await Promise.all([
+ readSetting(SETTING_KEYS.emailFromName, portId),
+ readSetting(SETTING_KEYS.emailFromAddress, portId),
+ readSetting(SETTING_KEYS.emailReplyTo, portId),
+ readSetting(SETTING_KEYS.emailSignatureHtml, portId),
+ readSetting(SETTING_KEYS.emailFooterHtml, portId),
+ readSetting(SETTING_KEYS.smtpHostOverride, portId),
+ readSetting(SETTING_KEYS.smtpPortOverride, portId),
+ readSetting(SETTING_KEYS.smtpUserOverride, portId),
+ readSetting(SETTING_KEYS.smtpPassOverride, portId),
+ ]);
+
+ // Parse env.SMTP_FROM into name + address if no port override
+ let envFromName = 'Port Nimara CRM';
+ let envFromAddress = `noreply@${env.SMTP_HOST}`;
+ if (env.SMTP_FROM) {
+ const match = env.SMTP_FROM.match(/^(.+?)\s*<(.+)>$/);
+ if (match) {
+ envFromName = match[1]!.trim();
+ envFromAddress = match[2]!.trim();
+ } else {
+ envFromAddress = env.SMTP_FROM;
+ }
+ }
+
+ return {
+ fromName: fromName ?? envFromName,
+ fromAddress: fromAddress ?? envFromAddress,
+ replyTo: replyTo ?? null,
+ signatureHtml: signatureHtml ?? null,
+ footerHtml: footerHtml ?? null,
+ smtpHost: smtpHost ?? env.SMTP_HOST,
+ smtpPort: smtpPort ?? env.SMTP_PORT,
+ smtpUser: smtpUser ?? env.SMTP_USER ?? null,
+ smtpPass: smtpPass ?? env.SMTP_PASS ?? null,
+ };
+}
+
+// ─── Documenso ──────────────────────────────────────────────────────────────
+
+export type EoiPathway = 'documenso-template' | 'inapp';
+
+export interface PortDocumensoConfig {
+ apiUrl: string;
+ apiKey: string;
+ eoiTemplateId: string | null;
+ defaultPathway: EoiPathway;
+}
+
+export async function getPortDocumensoConfig(portId: string): Promise {
+ const [apiUrl, apiKey, eoiTemplateId, defaultPathway] = await Promise.all([
+ readSetting(SETTING_KEYS.documensoApiUrlOverride, portId),
+ readSetting(SETTING_KEYS.documensoApiKeyOverride, portId),
+ readSetting(SETTING_KEYS.documensoEoiTemplateId, portId),
+ readSetting(SETTING_KEYS.eoiDefaultPathway, portId),
+ ]);
+
+ return {
+ apiUrl: apiUrl ?? env.DOCUMENSO_API_URL,
+ apiKey: apiKey ?? env.DOCUMENSO_API_KEY,
+ eoiTemplateId: eoiTemplateId ?? null,
+ defaultPathway: defaultPathway ?? 'documenso-template',
+ };
+}
+
+// ─── Branding ───────────────────────────────────────────────────────────────
+
+export interface PortBrandingConfig {
+ logoUrl: string | null;
+ primaryColor: string;
+ appName: string;
+ emailHeaderHtml: string | null;
+ emailFooterHtml: string | null;
+}
+
+const DEFAULT_BRANDING: PortBrandingConfig = {
+ logoUrl: null,
+ primaryColor: '#1e293b',
+ appName: 'Port Nimara CRM',
+ emailHeaderHtml: null,
+ emailFooterHtml: null,
+};
+
+export async function getPortBrandingConfig(portId: string): Promise {
+ const [logoUrl, primaryColor, appName, emailHeaderHtml, emailFooterHtml] = await Promise.all([
+ readSetting(SETTING_KEYS.brandingLogoUrl, portId),
+ readSetting(SETTING_KEYS.brandingPrimaryColor, portId),
+ readSetting(SETTING_KEYS.brandingAppName, portId),
+ readSetting(SETTING_KEYS.brandingEmailHeaderHtml, portId),
+ readSetting(SETTING_KEYS.brandingEmailFooterHtml, portId),
+ ]);
+
+ return {
+ logoUrl: logoUrl ?? DEFAULT_BRANDING.logoUrl,
+ primaryColor: primaryColor ?? DEFAULT_BRANDING.primaryColor,
+ appName: appName ?? DEFAULT_BRANDING.appName,
+ emailHeaderHtml: emailHeaderHtml ?? DEFAULT_BRANDING.emailHeaderHtml,
+ emailFooterHtml: emailFooterHtml ?? DEFAULT_BRANDING.emailFooterHtml,
+ };
+}
+
+// ─── Reminders ──────────────────────────────────────────────────────────────
+
+export interface PortReminderConfig {
+ defaultDays: number;
+ defaultEnabled: boolean;
+ digestEnabled: boolean;
+ digestTime: string; // 'HH:MM'
+ digestTimezone: string;
+}
+
+const DEFAULT_REMINDER: PortReminderConfig = {
+ defaultDays: 7,
+ defaultEnabled: false,
+ digestEnabled: false,
+ digestTime: '09:00',
+ digestTimezone: 'Europe/Warsaw',
+};
+
+export async function getPortReminderConfig(portId: string): Promise {
+ const [defaultDays, defaultEnabled, digestEnabled, digestTime, digestTimezone] =
+ await Promise.all([
+ readSetting(SETTING_KEYS.reminderDefaultDays, portId),
+ readSetting(SETTING_KEYS.reminderDefaultEnabled, portId),
+ readSetting(SETTING_KEYS.reminderDigestEnabled, portId),
+ readSetting(SETTING_KEYS.reminderDigestTime, portId),
+ readSetting(SETTING_KEYS.reminderDigestTimezone, portId),
+ ]);
+
+ return {
+ defaultDays: defaultDays ?? DEFAULT_REMINDER.defaultDays,
+ defaultEnabled: defaultEnabled ?? DEFAULT_REMINDER.defaultEnabled,
+ digestEnabled: digestEnabled ?? DEFAULT_REMINDER.digestEnabled,
+ digestTime: digestTime ?? DEFAULT_REMINDER.digestTime,
+ digestTimezone: digestTimezone ?? DEFAULT_REMINDER.digestTimezone,
+ };
+}
diff --git a/src/lib/validators/user-preferences.ts b/src/lib/validators/user-preferences.ts
new file mode 100644
index 0000000..40b4d37
--- /dev/null
+++ b/src/lib/validators/user-preferences.ts
@@ -0,0 +1,21 @@
+import { z } from 'zod';
+
+export const reminderPreferencesSchema = z.object({
+ delivery: z.enum(['immediate', 'daily', 'weekly', 'off']).default('immediate'),
+ digestTime: z
+ .string()
+ .regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Must be HH:MM')
+ .optional(),
+ digestDayOfWeek: z.number().int().min(0).max(6).optional(),
+ timezone: z.string().min(1).max(64).optional(),
+});
+
+export const updateUserPreferencesSchema = z.object({
+ darkMode: z.boolean().optional(),
+ locale: z.string().optional(),
+ timezone: z.string().optional(),
+ reminders: reminderPreferencesSchema.optional(),
+});
+
+export type UpdateUserPreferencesInput = z.infer;
+export type ReminderPreferences = z.infer;