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'} + + +
+
+
+ + setName(e.target.value)} + placeholder="e.g. Sales Manager" + required + /> +
+ +
+ +