diff --git a/src/app/(dashboard)/[portSlug]/admin/roles/page.tsx b/src/app/(dashboard)/[portSlug]/admin/roles/page.tsx
index ff3070e..920a55d 100644
--- a/src/app/(dashboard)/[portSlug]/admin/roles/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/roles/page.tsx
@@ -1,16 +1,5 @@
+import { RoleList } from '@/components/admin/roles/role-list';
+
export default function RoleManagementPage() {
- return (
-
-
-
Role Management
-
Configure roles and permissions
-
-
-
Coming in Layer 2
-
- This feature will be implemented in the next phase.
-
-
-
- );
+ return ;
}
diff --git a/src/app/(dashboard)/[portSlug]/admin/users/page.tsx b/src/app/(dashboard)/[portSlug]/admin/users/page.tsx
index 24b316e..2a767c0 100644
--- a/src/app/(dashboard)/[portSlug]/admin/users/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/users/page.tsx
@@ -1,16 +1,5 @@
+import { UserList } from '@/components/admin/users/user-list';
+
export default function UserManagementPage() {
- return (
-
-
-
User Management
-
Manage user accounts and access
-
-
-
Coming in Layer 2
-
- This feature will be implemented in the next phase.
-
-
-
- );
+ return ;
}
diff --git a/src/app/api/v1/admin/roles/[id]/route.ts b/src/app/api/v1/admin/roles/[id]/route.ts
index 3bc9726..f77e55d 100644
--- a/src/app/api/v1/admin/roles/[id]/route.ts
+++ b/src/app/api/v1/admin/roles/[id]/route.ts
@@ -1,28 +1,49 @@
import { NextResponse } from 'next/server';
-import { eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
-import { db } from '@/lib/db';
-import { roles } from '@/lib/db/schema';
-import { errorResponse, NotFoundError } from '@/lib/errors';
+import { parseBody } from '@/lib/api/route-helpers';
+import { getRole, updateRole, deleteRole } from '@/lib/services/roles.service';
+import { updateRoleSchema } from '@/lib/validators/roles';
+import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, _ctx, params) => {
try {
- const { id } = params;
- if (!id) {
- throw new NotFoundError('Role');
- }
-
- const role = await db.query.roles.findFirst({
- where: eq(roles.id, id),
- });
-
- if (!role) {
- throw new NotFoundError('Role');
- }
-
- return NextResponse.json({ data: role });
+ const data = await getRole(params.id!);
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
+
+export const PATCH = withAuth(
+ withPermission('admin', 'manage_users', async (req, ctx, params) => {
+ try {
+ const body = await parseBody(req, updateRoleSchema);
+ const data = await updateRole(params.id!, body, {
+ userId: ctx.userId,
+ portId: ctx.portId,
+ ipAddress: ctx.ipAddress,
+ userAgent: ctx.userAgent,
+ });
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
+
+export const DELETE = withAuth(
+ withPermission('admin', 'manage_users', async (_req, ctx, params) => {
+ try {
+ await deleteRole(params.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/roles/route.ts b/src/app/api/v1/admin/roles/route.ts
index 26a7799..52812e7 100644
--- a/src/app/api/v1/admin/roles/route.ts
+++ b/src/app/api/v1/admin/roles/route.ts
@@ -1,19 +1,35 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
-import { db } from '@/lib/db';
+import { parseBody } from '@/lib/api/route-helpers';
+import { listRoles, createRole } from '@/lib/services/roles.service';
+import { createRoleSchema } from '@/lib/validators/roles';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
- withPermission('admin', 'manage_users', async (_req, _ctx) => {
+ withPermission('admin', 'manage_users', async () => {
try {
- const data = await db.query.roles.findMany({
- orderBy: (roles, { asc }) => [asc(roles.name)],
- });
-
+ const data = await listRoles();
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
+
+export const POST = withAuth(
+ withPermission('admin', 'manage_users', async (req, ctx) => {
+ try {
+ const body = await parseBody(req, createRoleSchema);
+ const data = await createRole(body, {
+ userId: ctx.userId,
+ portId: ctx.portId,
+ ipAddress: ctx.ipAddress,
+ userAgent: ctx.userAgent,
+ });
+ return NextResponse.json({ data }, { status: 201 });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/admin/users/[id]/route.ts b/src/app/api/v1/admin/users/[id]/route.ts
new file mode 100644
index 0000000..4819e12
--- /dev/null
+++ b/src/app/api/v1/admin/users/[id]/route.ts
@@ -0,0 +1,51 @@
+import { NextResponse } from 'next/server';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { parseBody } from '@/lib/api/route-helpers';
+import { getUser, updateUser, removeUserFromPort } from '@/lib/services/users.service';
+import { updateUserSchema } from '@/lib/validators/users';
+import { errorResponse } from '@/lib/errors';
+
+export const GET = withAuth(
+ withPermission('admin', 'manage_users', async (_req, ctx, params) => {
+ try {
+ const data = await getUser(params.id!, ctx.portId);
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
+
+export const PATCH = withAuth(
+ withPermission('admin', 'manage_users', async (req, ctx, params) => {
+ try {
+ const body = await parseBody(req, updateUserSchema);
+ const data = await updateUser(params.id!, ctx.portId, body, {
+ userId: ctx.userId,
+ portId: ctx.portId,
+ ipAddress: ctx.ipAddress,
+ userAgent: ctx.userAgent,
+ });
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
+
+export const DELETE = withAuth(
+ withPermission('admin', 'manage_users', async (_req, ctx, params) => {
+ try {
+ await removeUserFromPort(params.id!, ctx.portId, {
+ 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/users/route.ts b/src/app/api/v1/admin/users/route.ts
index 4bf213a..287833b 100644
--- a/src/app/api/v1/admin/users/route.ts
+++ b/src/app/api/v1/admin/users/route.ts
@@ -1,40 +1,35 @@
import { NextResponse } from 'next/server';
-import { eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
-import { db } from '@/lib/db';
-import { userPortRoles, userProfiles, roles } from '@/lib/db/schema';
+import { parseBody } from '@/lib/api/route-helpers';
+import { listUsers, createUser } from '@/lib/services/users.service';
+import { createUserSchema } from '@/lib/validators/users';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx) => {
try {
- const rows = await db
- .select({
- userId: userPortRoles.userId,
- displayName: userProfiles.displayName,
- isActive: userProfiles.isActive,
- lastLoginAt: userProfiles.lastLoginAt,
- roleId: roles.id,
- roleName: roles.name,
- })
- .from(userPortRoles)
- .innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
- .innerJoin(roles, eq(userPortRoles.roleId, roles.id))
- .where(eq(userPortRoles.portId, ctx.portId))
- .orderBy(userProfiles.displayName);
-
- const data = rows.map((row) => ({
- userId: row.userId,
- displayName: row.displayName,
- isActive: row.isActive,
- lastLoginAt: row.lastLoginAt,
- role: { id: row.roleId, name: row.roleName },
- }));
-
+ const data = await listUsers(ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
+
+export const POST = withAuth(
+ withPermission('admin', 'manage_users', async (req, ctx) => {
+ try {
+ const body = await parseBody(req, createUserSchema);
+ const data = await createUser(ctx.portId, body, {
+ userId: ctx.userId,
+ portId: ctx.portId,
+ ipAddress: ctx.ipAddress,
+ userAgent: ctx.userAgent,
+ });
+ return NextResponse.json({ data }, { status: 201 });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/components/admin/roles/role-form.tsx b/src/components/admin/roles/role-form.tsx
new file mode 100644
index 0000000..c5e72e9
--- /dev/null
+++ b/src/components/admin/roles/role-form.tsx
@@ -0,0 +1,297 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Checkbox } from '@/components/ui/checkbox';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from '@/components/ui/accordion';
+import { apiFetch } from '@/lib/api/client';
+
+/** Default permissions structure matching RolePermissions type */
+const DEFAULT_PERMISSIONS: Record> = {
+ clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
+ interests: {
+ view: false,
+ create: false,
+ edit: false,
+ delete: false,
+ change_stage: false,
+ generate_eoi: false,
+ export: false,
+ },
+ berths: { view: false, edit: false, import: false, manage_waiting_list: false },
+ documents: {
+ view: false,
+ create: false,
+ send_for_signing: false,
+ upload_signed: false,
+ delete: false,
+ },
+ expenses: {
+ view: false,
+ create: false,
+ edit: false,
+ delete: false,
+ export: false,
+ scan_receipt: false,
+ },
+ invoices: {
+ view: false,
+ create: false,
+ edit: false,
+ delete: false,
+ send: false,
+ record_payment: false,
+ export: false,
+ },
+ files: { view: false, upload: false, delete: false, manage_folders: false },
+ email: { view: false, send: false, configure_account: false },
+ reminders: {
+ view_own: false,
+ view_all: false,
+ create: false,
+ edit_own: false,
+ edit_all: false,
+ assign_others: false,
+ },
+ calendar: { connect: false, view_events: false },
+ reports: { view_dashboard: false, view_analytics: false, export: false },
+ document_templates: { view: false, generate: false, manage: false },
+ admin: {
+ manage_users: false,
+ view_audit_log: false,
+ manage_settings: false,
+ manage_webhooks: false,
+ manage_reports: false,
+ manage_custom_fields: false,
+ manage_forms: false,
+ manage_tags: false,
+ system_backup: false,
+ },
+};
+
+const GROUP_LABELS: Record = {
+ clients: 'Clients',
+ interests: 'Interests / Pipeline',
+ berths: 'Berths',
+ documents: 'Documents',
+ expenses: 'Expenses',
+ invoices: 'Invoices',
+ files: 'Files',
+ email: 'Email',
+ reminders: 'Reminders',
+ calendar: 'Calendar',
+ reports: 'Reports',
+ document_templates: 'Document Templates',
+ admin: 'Administration',
+};
+
+function formatAction(action: string): string {
+ return action.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+interface RoleFormProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ role?: {
+ id: string;
+ name: string;
+ description: string | null;
+ isSystem: boolean;
+ permissions: Record>;
+ } | null;
+ onSuccess: () => void;
+}
+
+export function RoleForm({ open, onOpenChange, role, onSuccess }: RoleFormProps) {
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [permissions, setPermissions] = useState>>(
+ structuredClone(DEFAULT_PERMISSIONS),
+ );
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const isEdit = !!role;
+
+ useEffect(() => {
+ if (open) {
+ if (role) {
+ setName(role.name);
+ setDescription(role.description ?? '');
+ // Merge role permissions over defaults to fill any missing keys
+ const merged = structuredClone(DEFAULT_PERMISSIONS);
+ for (const [group, actions] of Object.entries(role.permissions)) {
+ if (merged[group]) {
+ for (const [action, value] of Object.entries(actions as Record)) {
+ merged[group]![action] = value;
+ }
+ }
+ }
+ setPermissions(merged);
+ } else {
+ setName('');
+ setDescription('');
+ setPermissions(structuredClone(DEFAULT_PERMISSIONS));
+ }
+ setError(null);
+ }
+ }, [open, role]);
+
+ function togglePermission(group: string, action: string) {
+ setPermissions((prev) => {
+ const next = structuredClone(prev);
+ next[group]![action] = !next[group]![action];
+ return next;
+ });
+ }
+
+ function toggleGroup(group: string, value: boolean) {
+ setPermissions((prev) => {
+ const next = structuredClone(prev);
+ for (const action of Object.keys(next[group]!)) {
+ next[group]![action] = value;
+ }
+ return next;
+ });
+ }
+
+ function isGroupAllChecked(group: string): boolean {
+ return Object.values(permissions[group]!).every(Boolean);
+ }
+
+ function isGroupPartial(group: string): boolean {
+ const vals = Object.values(permissions[group]!);
+ return vals.some(Boolean) && !vals.every(Boolean);
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ setLoading(true);
+
+ try {
+ if (isEdit) {
+ await apiFetch(`/api/v1/admin/roles/${role.id}`, {
+ method: 'PATCH',
+ body: { name, description: description || null, permissions },
+ });
+ } else {
+ await apiFetch('/api/v1/admin/roles', {
+ method: 'POST',
+ body: { name, description: description || undefined, permissions },
+ });
+ }
+ onSuccess();
+ onOpenChange(false);
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Something went wrong';
+ setError(message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+ {isEdit ? 'Edit Role' : 'New Role'}
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/roles/role-list.tsx b/src/components/admin/roles/role-list.tsx
new file mode 100644
index 0000000..c4c756b
--- /dev/null
+++ b/src/components/admin/roles/role-list.tsx
@@ -0,0 +1,176 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { type ColumnDef } from '@tanstack/react-table';
+import { Pencil, Trash2, Plus, Lock } from 'lucide-react';
+
+import { DataTable } from '@/components/shared/data-table';
+import { PageHeader } from '@/components/shared/page-header';
+import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { apiFetch } from '@/lib/api/client';
+import { RoleForm } from './role-form';
+
+interface Role {
+ id: string;
+ name: string;
+ description: string | null;
+ isSystem: boolean;
+ isGlobal: boolean;
+ permissions: Record>;
+ createdAt: string;
+}
+
+export function RoleList() {
+ const [roles, setRoles] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [formOpen, setFormOpen] = useState(false);
+ const [editingRole, setEditingRole] = useState(null);
+ const [deletingId, setDeletingId] = useState(null);
+
+ const fetchRoles = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await apiFetch<{ data: Role[] }>('/api/v1/admin/roles');
+ setRoles(res.data);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void fetchRoles();
+ }, [fetchRoles]);
+
+ function handleNewRole() {
+ setEditingRole(null);
+ setFormOpen(true);
+ }
+
+ function handleEditRole(role: Role) {
+ setEditingRole(role);
+ setFormOpen(true);
+ }
+
+ async function handleDeleteRole(id: string) {
+ setDeletingId(id);
+ try {
+ await apiFetch(`/api/v1/admin/roles/${id}`, { method: 'DELETE' });
+ await fetchRoles();
+ } finally {
+ setDeletingId(null);
+ }
+ }
+
+ function countPermissions(perms: Record>): string {
+ let granted = 0;
+ let total = 0;
+ for (const group of Object.values(perms)) {
+ for (const val of Object.values(group)) {
+ total++;
+ if (val) granted++;
+ }
+ }
+ return `${granted}/${total}`;
+ }
+
+ const columns: ColumnDef[] = [
+ {
+ accessorKey: 'name',
+ header: 'Name',
+ cell: ({ row }) => (
+
+ {row.original.name}
+ {row.original.isSystem && (
+
+
+ System
+
+ )}
+
+ ),
+ },
+ {
+ accessorKey: 'description',
+ header: 'Description',
+ cell: ({ row }) => (
+ {row.original.description ?? '—'}
+ ),
+ },
+ {
+ id: 'permissions',
+ header: 'Permissions',
+ cell: ({ row }) => (
+ {countPermissions(row.original.permissions)}
+ ),
+ },
+ {
+ id: 'actions',
+ header: '',
+ cell: ({ row }) => (
+
+
+ {!row.original.isSystem && (
+
+
+ Delete
+
+ }
+ title="Delete Role"
+ description={`Delete "${row.original.name}"? Users assigned to this role must be reassigned first.`}
+ confirmLabel="Delete"
+ onConfirm={() => handleDeleteRole(row.original.id)}
+ loading={deletingId === row.original.id}
+ />
+ )}
+
+ ),
+ enableSorting: false,
+ size: 80,
+ },
+ ];
+
+ return (
+
+
+
+ New Role
+
+ }
+ />
+
+ row.id}
+ emptyState={
+
+ }
+ />
+
+
+
+ );
+}
diff --git a/src/components/admin/users/user-form.tsx b/src/components/admin/users/user-form.tsx
new file mode 100644
index 0000000..dbd4ac8
--- /dev/null
+++ b/src/components/admin/users/user-form.tsx
@@ -0,0 +1,222 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Switch } from '@/components/ui/switch';
+import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
+import { apiFetch } from '@/lib/api/client';
+
+interface Role {
+ id: string;
+ name: string;
+}
+
+interface UserFormProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ user?: {
+ userId: string;
+ displayName: string;
+ email: string;
+ phone: string | null;
+ isActive: boolean;
+ role: { id: string; name: string };
+ } | null;
+ onSuccess: () => void;
+}
+
+export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps) {
+ const [roles, setRoles] = useState([]);
+ const [name, setName] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [displayName, setDisplayName] = useState('');
+ const [phone, setPhone] = useState('');
+ const [roleId, setRoleId] = useState('');
+ const [isActive, setIsActive] = useState(true);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const isEdit = !!user;
+
+ useEffect(() => {
+ if (open) {
+ void apiFetch<{ data: Role[] }>('/api/v1/admin/roles').then((res) => setRoles(res.data));
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (open) {
+ if (user) {
+ setName(user.displayName);
+ setEmail(user.email);
+ setDisplayName(user.displayName);
+ setPhone(user.phone ?? '');
+ setRoleId(user.role.id);
+ setIsActive(user.isActive);
+ setPassword('');
+ } else {
+ setName('');
+ setEmail('');
+ setDisplayName('');
+ setPhone('');
+ setRoleId('');
+ setIsActive(true);
+ setPassword('');
+ }
+ setError(null);
+ }
+ }, [open, user]);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ setLoading(true);
+
+ try {
+ if (isEdit) {
+ await apiFetch(`/api/v1/admin/users/${user.userId}`, {
+ method: 'PATCH',
+ body: {
+ displayName,
+ phone: phone || null,
+ roleId,
+ isActive,
+ },
+ });
+ } else {
+ await apiFetch('/api/v1/admin/users', {
+ method: 'POST',
+ body: {
+ name: name || displayName,
+ email,
+ password,
+ displayName,
+ phone: phone || undefined,
+ roleId,
+ },
+ });
+ }
+ onSuccess();
+ onOpenChange(false);
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Something went wrong';
+ setError(message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+ {isEdit ? 'Edit User' : 'New User'}
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/users/user-list.tsx b/src/components/admin/users/user-list.tsx
new file mode 100644
index 0000000..f4a53ee
--- /dev/null
+++ b/src/components/admin/users/user-list.tsx
@@ -0,0 +1,173 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { type ColumnDef } from '@tanstack/react-table';
+import { Pencil, Trash2, Plus, ShieldCheck, ShieldOff } from 'lucide-react';
+
+import { DataTable } from '@/components/shared/data-table';
+import { PageHeader } from '@/components/shared/page-header';
+import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { apiFetch } from '@/lib/api/client';
+import { UserForm } from './user-form';
+
+interface UserRow {
+ userId: string;
+ displayName: string;
+ email: string;
+ phone: string | null;
+ isActive: boolean;
+ isSuperAdmin: boolean;
+ lastLoginAt: string | null;
+ role: { id: string; name: string };
+ assignedAt: string;
+}
+
+export function UserList() {
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [formOpen, setFormOpen] = useState(false);
+ const [editingUser, setEditingUser] = useState(null);
+ const [deletingId, setDeletingId] = useState(null);
+
+ const fetchUsers = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await apiFetch<{ data: UserRow[] }>('/api/v1/admin/users');
+ setUsers(res.data);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void fetchUsers();
+ }, [fetchUsers]);
+
+ function handleNewUser() {
+ setEditingUser(null);
+ setFormOpen(true);
+ }
+
+ function handleEditUser(user: UserRow) {
+ setEditingUser(user);
+ setFormOpen(true);
+ }
+
+ async function handleRemoveUser(userId: string) {
+ setDeletingId(userId);
+ try {
+ await apiFetch(`/api/v1/admin/users/${userId}`, { method: 'DELETE' });
+ await fetchUsers();
+ } finally {
+ setDeletingId(null);
+ }
+ }
+
+ const columns: ColumnDef[] = [
+ {
+ accessorKey: 'displayName',
+ header: 'Name',
+ cell: ({ row }) => (
+
+ {row.original.displayName}
+ {row.original.email}
+
+ ),
+ },
+ {
+ accessorKey: 'role',
+ header: 'Role',
+ cell: ({ row }) => {row.original.role.name},
+ },
+ {
+ accessorKey: 'isActive',
+ header: 'Status',
+ cell: ({ row }) =>
+ row.original.isActive ? (
+
+
+ Active
+
+ ) : (
+
+
+ Disabled
+
+ ),
+ },
+ {
+ accessorKey: 'lastLoginAt',
+ header: 'Last Login',
+ cell: ({ row }) =>
+ row.original.lastLoginAt
+ ? new Date(row.original.lastLoginAt).toLocaleDateString()
+ : 'Never',
+ },
+ {
+ id: 'actions',
+ header: '',
+ cell: ({ row }) => (
+
+
+
+
+ Remove
+
+ }
+ title="Remove User"
+ description={`Remove "${row.original.displayName}" from this port? They will lose access but their account remains.`}
+ confirmLabel="Remove"
+ onConfirm={() => handleRemoveUser(row.original.userId)}
+ loading={deletingId === row.original.userId}
+ />
+
+ ),
+ enableSorting: false,
+ size: 80,
+ },
+ ];
+
+ return (
+
+
+
+ New User
+
+ }
+ />
+
+ row.userId}
+ emptyState={
+
+
No users assigned to this port.
+
+
+ }
+ />
+
+
+
+ );
+}
diff --git a/src/lib/services/roles.service.ts b/src/lib/services/roles.service.ts
new file mode 100644
index 0000000..32fe3d7
--- /dev/null
+++ b/src/lib/services/roles.service.ts
@@ -0,0 +1,148 @@
+import { eq } from 'drizzle-orm';
+
+import { db } from '@/lib/db';
+import { roles, userPortRoles } from '@/lib/db/schema';
+import type { RolePermissions } from '@/lib/db/schema/users';
+import { createAuditLog } from '@/lib/audit';
+import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
+import { emitToRoom } from '@/lib/socket/server';
+import type { CreateRoleInput, UpdateRoleInput } from '@/lib/validators/roles';
+
+interface AuditMeta {
+ userId: string;
+ portId: string;
+ ipAddress: string;
+ userAgent: string;
+}
+
+export async function listRoles() {
+ return db.select().from(roles).orderBy(roles.name);
+}
+
+export async function getRole(id: string) {
+ const role = await db.query.roles.findFirst({
+ where: eq(roles.id, id),
+ });
+ if (!role) throw new NotFoundError('Role');
+ return role;
+}
+
+export async function createRole(data: CreateRoleInput, meta: AuditMeta) {
+ // Check name uniqueness
+ const existing = await db.query.roles.findFirst({
+ where: eq(roles.name, data.name),
+ });
+ if (existing) {
+ throw new ConflictError(`A role named "${data.name}" already exists`);
+ }
+
+ const [role] = await db
+ .insert(roles)
+ .values({
+ name: data.name,
+ description: data.description ?? null,
+ permissions: data.permissions as RolePermissions,
+ })
+ .returning();
+
+ void createAuditLog({
+ userId: meta.userId,
+ portId: meta.portId,
+ action: 'create',
+ entityType: 'role',
+ entityId: role!.id,
+ newValue: { name: role!.name },
+ ipAddress: meta.ipAddress,
+ userAgent: meta.userAgent,
+ });
+
+ emitToRoom(`port:${meta.portId}`, 'system:alert', {
+ alertType: 'role:created',
+ message: `Role "${role!.name}" created`,
+ severity: 'info',
+ });
+
+ return role!;
+}
+
+export async function updateRole(id: string, data: UpdateRoleInput, meta: AuditMeta) {
+ const role = await db.query.roles.findFirst({
+ where: eq(roles.id, id),
+ });
+ if (!role) throw new NotFoundError('Role');
+
+ // Check name uniqueness if changing name
+ if (data.name && data.name !== role.name) {
+ const conflict = await db.query.roles.findFirst({
+ where: eq(roles.name, data.name),
+ });
+ if (conflict) {
+ throw new ConflictError(`A role named "${data.name}" already exists`);
+ }
+ }
+
+ const updates: Record = { updatedAt: new Date() };
+ if (data.name !== undefined) updates.name = data.name;
+ if (data.description !== undefined) updates.description = data.description;
+ if (data.permissions !== undefined) updates.permissions = data.permissions as RolePermissions;
+
+ const [updated] = await db.update(roles).set(updates).where(eq(roles.id, id)).returning();
+
+ void createAuditLog({
+ userId: meta.userId,
+ portId: meta.portId,
+ action: 'update',
+ entityType: 'role',
+ entityId: id,
+ oldValue: { name: role.name, permissions: role.permissions },
+ newValue: { name: updated!.name, permissions: updated!.permissions },
+ ipAddress: meta.ipAddress,
+ userAgent: meta.userAgent,
+ });
+
+ emitToRoom(`port:${meta.portId}`, 'system:alert', {
+ alertType: 'role:updated',
+ message: `Role "${updated!.name}" updated`,
+ severity: 'info',
+ });
+
+ return updated!;
+}
+
+export async function deleteRole(id: string, meta: AuditMeta) {
+ const role = await db.query.roles.findFirst({
+ where: eq(roles.id, id),
+ });
+ if (!role) throw new NotFoundError('Role');
+
+ if (role.isSystem) {
+ throw new ValidationError('System roles cannot be deleted');
+ }
+
+ // Check if any users are assigned this role
+ const assignments = await db.query.userPortRoles.findFirst({
+ where: eq(userPortRoles.roleId, id),
+ });
+ if (assignments) {
+ throw new ConflictError('Cannot delete a role that is assigned to users. Reassign them first.');
+ }
+
+ await db.delete(roles).where(eq(roles.id, id));
+
+ void createAuditLog({
+ userId: meta.userId,
+ portId: meta.portId,
+ action: 'delete',
+ entityType: 'role',
+ entityId: id,
+ oldValue: { name: role.name },
+ ipAddress: meta.ipAddress,
+ userAgent: meta.userAgent,
+ });
+
+ emitToRoom(`port:${meta.portId}`, 'system:alert', {
+ alertType: 'role:deleted',
+ message: `Role "${role.name}" deleted`,
+ severity: 'info',
+ });
+}
diff --git a/src/lib/services/users.service.ts b/src/lib/services/users.service.ts
new file mode 100644
index 0000000..693ce84
--- /dev/null
+++ b/src/lib/services/users.service.ts
@@ -0,0 +1,243 @@
+import { and, eq } from 'drizzle-orm';
+
+import { db } from '@/lib/db';
+import { user, userProfiles, userPortRoles, roles } from '@/lib/db/schema';
+import { auth } from '@/lib/auth';
+import { createAuditLog } from '@/lib/audit';
+import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
+import { emitToRoom } from '@/lib/socket/server';
+import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users';
+
+interface AuditMeta {
+ userId: string;
+ portId: string;
+ ipAddress: string;
+ userAgent: string;
+}
+
+export async function listUsers(portId: string) {
+ const rows = await db
+ .select({
+ userId: userPortRoles.userId,
+ displayName: userProfiles.displayName,
+ email: user.email,
+ phone: userProfiles.phone,
+ isActive: userProfiles.isActive,
+ isSuperAdmin: userProfiles.isSuperAdmin,
+ lastLoginAt: userProfiles.lastLoginAt,
+ roleId: roles.id,
+ roleName: roles.name,
+ assignedAt: userPortRoles.createdAt,
+ })
+ .from(userPortRoles)
+ .innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
+ .innerJoin(user, eq(userPortRoles.userId, user.id))
+ .innerJoin(roles, eq(userPortRoles.roleId, roles.id))
+ .where(eq(userPortRoles.portId, portId))
+ .orderBy(userProfiles.displayName);
+
+ return rows.map((row) => ({
+ userId: row.userId,
+ displayName: row.displayName,
+ email: row.email,
+ phone: row.phone,
+ isActive: row.isActive,
+ isSuperAdmin: row.isSuperAdmin,
+ lastLoginAt: row.lastLoginAt,
+ role: { id: row.roleId, name: row.roleName },
+ assignedAt: row.assignedAt,
+ }));
+}
+
+export async function getUser(userId: string, portId: string) {
+ const profile = await db.query.userProfiles.findFirst({
+ where: eq(userProfiles.userId, userId),
+ });
+ if (!profile) throw new NotFoundError('User');
+
+ const authUser = await db.query.user.findFirst({
+ where: eq(user.id, userId),
+ });
+
+ const portRole = await db.query.userPortRoles.findFirst({
+ where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
+ with: { role: true },
+ });
+ if (!portRole) throw new NotFoundError('User not assigned to this port');
+
+ return {
+ userId: profile.userId,
+ displayName: profile.displayName,
+ email: authUser?.email ?? '',
+ phone: profile.phone,
+ isActive: profile.isActive,
+ isSuperAdmin: profile.isSuperAdmin,
+ lastLoginAt: profile.lastLoginAt,
+ avatarUrl: profile.avatarUrl,
+ preferences: profile.preferences,
+ role: { id: portRole.role.id, name: portRole.role.name },
+ createdAt: profile.createdAt,
+ };
+}
+
+export async function createUser(portId: string, data: CreateUserInput, meta: AuditMeta) {
+ // Check email uniqueness
+ const existingUser = await db.query.user.findFirst({
+ where: eq(user.email, data.email.toLowerCase()),
+ });
+ if (existingUser) {
+ throw new ConflictError('A user with this email already exists');
+ }
+
+ // Validate role exists
+ const role = await db.query.roles.findFirst({
+ where: eq(roles.id, data.roleId),
+ });
+ if (!role) throw new ValidationError('Invalid role ID');
+
+ // Create Better Auth user
+ const authResult = await auth.api.signUpEmail({
+ body: {
+ email: data.email.toLowerCase(),
+ password: data.password,
+ name: data.name,
+ },
+ });
+
+ const newUserId = authResult.user.id;
+
+ // Create CRM profile
+ await db.insert(userProfiles).values({
+ userId: newUserId,
+ displayName: data.displayName,
+ phone: data.phone ?? null,
+ });
+
+ // Assign to port with role
+ await db.insert(userPortRoles).values({
+ userId: newUserId,
+ portId,
+ roleId: data.roleId,
+ assignedBy: meta.userId,
+ });
+
+ void createAuditLog({
+ userId: meta.userId,
+ portId,
+ action: 'create',
+ entityType: 'user',
+ entityId: newUserId,
+ newValue: { email: data.email, displayName: data.displayName, role: role.name },
+ ipAddress: meta.ipAddress,
+ userAgent: meta.userAgent,
+ });
+
+ emitToRoom(`port:${portId}`, 'system:alert', {
+ alertType: 'user:created',
+ message: `User "${data.displayName}" added`,
+ severity: 'info',
+ });
+
+ return getUser(newUserId, portId);
+}
+
+export async function updateUser(
+ userId: string,
+ portId: string,
+ data: UpdateUserInput,
+ meta: AuditMeta,
+) {
+ const profile = await db.query.userProfiles.findFirst({
+ where: eq(userProfiles.userId, userId),
+ });
+ if (!profile) throw new NotFoundError('User');
+
+ const portRole = await db.query.userPortRoles.findFirst({
+ where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
+ });
+ if (!portRole) throw new NotFoundError('User not assigned to this port');
+
+ // Update profile fields
+ const profileUpdates: Record = { updatedAt: new Date() };
+ if (data.displayName !== undefined) profileUpdates.displayName = data.displayName;
+ if (data.phone !== undefined) profileUpdates.phone = data.phone;
+ if (data.isActive !== undefined) profileUpdates.isActive = data.isActive;
+
+ if (Object.keys(profileUpdates).length > 1) {
+ await db.update(userProfiles).set(profileUpdates).where(eq(userProfiles.userId, userId));
+ }
+
+ // Update role assignment
+ if (data.roleId && data.roleId !== portRole.roleId) {
+ const newRole = await db.query.roles.findFirst({
+ where: eq(roles.id, data.roleId),
+ });
+ if (!newRole) throw new ValidationError('Invalid role ID');
+
+ await db
+ .update(userPortRoles)
+ .set({ roleId: data.roleId, assignedBy: meta.userId })
+ .where(and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)));
+ }
+
+ void createAuditLog({
+ userId: meta.userId,
+ portId,
+ action: 'update',
+ entityType: 'user',
+ entityId: userId,
+ oldValue: {
+ displayName: profile.displayName,
+ isActive: profile.isActive,
+ roleId: portRole.roleId,
+ },
+ newValue: data,
+ ipAddress: meta.ipAddress,
+ userAgent: meta.userAgent,
+ });
+
+ emitToRoom(`port:${portId}`, 'system:alert', {
+ alertType: 'user:updated',
+ message: `User "${data.displayName ?? profile.displayName}" updated`,
+ severity: 'info',
+ });
+
+ return getUser(userId, portId);
+}
+
+export async function removeUserFromPort(userId: string, portId: string, meta: AuditMeta) {
+ const portRole = await db.query.userPortRoles.findFirst({
+ where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)),
+ });
+ if (!portRole) throw new NotFoundError('User not assigned to this port');
+
+ // Prevent removing yourself
+ if (userId === meta.userId) {
+ throw new ValidationError('Cannot remove yourself from the port');
+ }
+
+ await db
+ .delete(userPortRoles)
+ .where(and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)));
+
+ const profile = await db.query.userProfiles.findFirst({
+ where: eq(userProfiles.userId, userId),
+ });
+
+ void createAuditLog({
+ userId: meta.userId,
+ portId,
+ action: 'delete',
+ entityType: 'user',
+ entityId: userId,
+ oldValue: { displayName: profile?.displayName, roleId: portRole.roleId },
+ ipAddress: meta.ipAddress,
+ userAgent: meta.userAgent,
+ });
+
+ emitToRoom(`port:${portId}`, 'system:alert', {
+ alertType: 'user:removed',
+ message: `User "${profile?.displayName}" removed from port`,
+ severity: 'info',
+ });
+}
diff --git a/src/lib/validators/roles.ts b/src/lib/validators/roles.ts
new file mode 100644
index 0000000..560a844
--- /dev/null
+++ b/src/lib/validators/roles.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod';
+
+const permissionGroupSchema = z.record(z.string(), z.boolean());
+
+const rolePermissionsSchema = z.object({
+ clients: permissionGroupSchema,
+ interests: permissionGroupSchema,
+ berths: permissionGroupSchema,
+ documents: permissionGroupSchema,
+ expenses: permissionGroupSchema,
+ invoices: permissionGroupSchema,
+ files: permissionGroupSchema,
+ email: permissionGroupSchema,
+ reminders: permissionGroupSchema,
+ calendar: permissionGroupSchema,
+ reports: permissionGroupSchema,
+ document_templates: permissionGroupSchema,
+ admin: permissionGroupSchema,
+});
+
+export const createRoleSchema = z.object({
+ name: z.string().min(1).max(100),
+ description: z.string().max(500).optional(),
+ permissions: rolePermissionsSchema,
+});
+
+export type CreateRoleInput = z.infer;
+
+export const updateRoleSchema = z.object({
+ name: z.string().min(1).max(100).optional(),
+ description: z.string().max(500).nullable().optional(),
+ permissions: rolePermissionsSchema.optional(),
+});
+
+export type UpdateRoleInput = z.infer;
diff --git a/src/lib/validators/users.ts b/src/lib/validators/users.ts
new file mode 100644
index 0000000..ac1344f
--- /dev/null
+++ b/src/lib/validators/users.ts
@@ -0,0 +1,27 @@
+import { z } from 'zod';
+
+export const createUserSchema = z.object({
+ email: z.string().email(),
+ name: z.string().min(1).max(200),
+ password: z.string().min(12),
+ displayName: z.string().min(1).max(200),
+ phone: z.string().optional(),
+ roleId: z.string().uuid(),
+});
+
+export type CreateUserInput = z.infer;
+
+export const updateUserSchema = z.object({
+ displayName: z.string().min(1).max(200).optional(),
+ phone: z.string().nullable().optional(),
+ isActive: z.boolean().optional(),
+ roleId: z.string().uuid().optional(),
+});
+
+export type UpdateUserInput = z.infer;
+
+export const resetPasswordSchema = z.object({
+ newPassword: z.string().min(12),
+});
+
+export type ResetPasswordInput = z.infer;