From 6e91cded2da70483289a80759385c8ded96d847f Mon Sep 17 00:00:00 2001 From: barisgit Date: Wed, 23 Apr 2025 17:35:19 +0200 Subject: [PATCH 1/8] Fix CI running twice for development --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0b1410..396a9cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,6 @@ name: CI on: - push: - branches: - - development pull_request: branches: - main From 9c4b8c064f106e58524701cc435e075d9c9cba26 Mon Sep 17 00:00:00 2001 From: barisgit Date: Wed, 23 Apr 2025 19:00:30 +0200 Subject: [PATCH 2/8] Add dbml schema --- packages/db/src/schema/index.ts | 7 +- schema.dbml | 209 ++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 schema.dbml diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index ce789dd..bfc2f3f 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -442,9 +442,10 @@ export const verificationTokens = pgTable( token: varchar("token", { length: 255 }).notNull(), expires: timestamp("expires").notNull(), }, - (t) => ({ - pk: primaryKey({ columns: [t.identifier, t.token] }), - }) + (t) => [ + primaryKey({ columns: [t.identifier, t.token] }), + index("verification_token_idx").on(t.identifier, t.token), + ] ); // Assets table for storing uploaded files diff --git a/schema.dbml b/schema.dbml new file mode 100644 index 0000000..757e2f8 --- /dev/null +++ b/schema.dbml @@ -0,0 +1,209 @@ +// Database Markup Language for NextWiki Schema + +// Define PostgreSQL specific types or extensions if needed +// Note: pg_trgm extension is used but not representable directly in standard DBML +// Note: tsvector type is used but not representable directly in standard DBML + +Enum editor_type { + markdown + html +} + +Table USERS { + id int [pk, increment] + name varchar(255) + email varchar(255) [unique, not null] + password varchar(255) + email_verified timestamp + image text + created_at timestamp [default: `now()`] + updated_at timestamp [default: `now()`] + + indexes { + email_idx (email) + } +} + +Table PERMISSIONS { + id int [pk, increment] + module varchar(50) [not null] + resource varchar(50) [not null] + action varchar(50) [not null] + name varchar(100) [unique, not null, note: 'Generated: module || ":" || resource || ":" || action'] + description text + created_at timestamp [default: `now()`] +} + +Table GROUPS { + id int [pk, increment] + name varchar(100) [unique, not null] + description text + created_at timestamp [default: `now()`] + updated_at timestamp [default: `now()`] + is_system boolean [default: false] + is_editable boolean [default: true] + allow_user_assignment boolean [default: true] +} + +Table USER_GROUPS { + user_id int [not null, ref: > USERS.id] + group_id int [not null, ref: > GROUPS.id] + created_at timestamp [default: `now()`] + + indexes { + (user_id, group_id) [pk] + user_group_idx (user_id, group_id) + } +} + +Table GROUP_PERMISSIONS { + group_id int [not null, ref: > GROUPS.id] + permission_id int [not null, ref: > PERMISSIONS.id] + created_at timestamp [default: `now()`] + + indexes { + (group_id, permission_id) [pk] + group_permission_idx (group_id, permission_id) + } +} + +Table GROUP_MODULE_PERMISSIONS { + group_id int [not null, ref: > GROUPS.id] + module varchar(50) [not null] + created_at timestamp [default: `now()`] + + indexes { + (group_id, module) [pk] + group_module_permissions_idx (group_id, module) + } +} + +Table GROUP_ACTION_PERMISSIONS { + group_id int [not null, ref: > GROUPS.id] + action varchar(50) [not null] + created_at timestamp [default: `now()`] + + indexes { + (group_id, action) [pk] + group_action_permissions_idx (group_id, action) + } +} + +Table PAGE_PERMISSIONS { + id int [pk, increment] + page_id int [not null, ref: > WIKI_PAGES.id] + group_id int [ref: > GROUPS.id] + permission_id int [not null, ref: > PERMISSIONS.id] + permission_type varchar(10) [not null, default: 'allow'] // 'allow' or 'deny' + created_at timestamp [default: `now()`] + + indexes { + // Note: Drizzle schema has index on (pageId, permissionId, groupId) which implies uniqueness + // DBML might require explicitly marking as unique if needed. + page_group_perm_idx (page_id, permission_id, group_id) + } +} + +Table WIKI_PAGES { + id int [pk, increment] + path varchar(1000) [unique, not null] + title varchar(255) [not null] + content text + rendered_html text + editor_type editor_type + is_published boolean [default: false] + created_by_id int [not null, ref: > USERS.id] + created_at timestamp [default: `now()`] + updated_by_id int [not null, ref: > USERS.id] + updated_at timestamp [default: `now()`] + rendered_html_updated_at timestamp + locked_by_id int [ref: > USERS.id] + locked_at timestamp + lock_expires_at timestamp + search text [not null, note: "Generated tsvector: setweight(to_tsvector('english', title), 'A') || setweight(to_tsvector('english', content), 'B')"] // Representing tsvector as text + + indexes { + idx_search (search) + trgm_idx_title (title) + } +} + +Table WIKI_PAGE_REVISIONS { + id int [pk, increment] + page_id int [not null, ref: > WIKI_PAGES.id] + content text [not null] + created_by_id int [ref: > USERS.id] + created_at timestamp [default: `now()`] +} + +Table WIKI_TAGS { + id int [pk, increment] + name varchar(100) [unique, not null] + description text + created_at timestamp [default: `now()`] +} + +Table WIKI_PAGE_TO_TAG { + page_id int [not null, ref: > WIKI_PAGES.id] + tag_id int [not null, ref: > WIKI_TAGS.id] + + indexes { + (page_id, tag_id) [pk] + } +} + +Table ACCOUNTS { + id int [pk, increment] + user_id int [not null, ref: > USERS.id] + type varchar(255) [not null] + provider varchar(255) [not null] + provider_account_id varchar(255) [not null] + refresh_token text + access_token text + expires_at int + token_type varchar(255) + scope varchar(255) + id_token text + session_state varchar(255) + created_at timestamp [default: `now()`] + updated_at timestamp [default: `now()`] +} + +Table SESSIONS { + id int [pk, increment] + session_token varchar(255) [unique, not null] + user_id int [not null, ref: > USERS.id] + expires timestamp [not null] +} + +Table VERIFICATION_TOKENS { + identifier varchar(255) [not null] + token varchar(255) [not null] + expires timestamp [not null] + + indexes { + (identifier, token) [pk] + } +} + +Table ASSETS { + id uuid [pk, default: `random_uuid()`] // Assuming DBML understands uuid or similar + name varchar(255) + description text + file_name varchar(255) [not null] + file_type varchar(100) [not null] + file_size int [not null] + data text [not null, note: 'Base64 encoded file data'] + uploaded_by_id int [not null, ref: > USERS.id] + created_at timestamp [default: `now()`] +} + +Table ASSETS_TO_PAGES { + asset_id uuid [not null, ref: > ASSETS.id] + page_id int [not null, ref: > WIKI_PAGES.id] + + indexes { + (asset_id, page_id) [pk] + asset_page_idx (asset_id, page_id) + } +} \ No newline at end of file From 3c2aeaa450a3d12f0fd1e30ddcecb1f26a98f489 Mon Sep 17 00:00:00 2001 From: barisgit Date: Wed, 23 Apr 2025 21:50:40 +0200 Subject: [PATCH 3/8] Refactor permissions to use exernalized action and module tables --- apps/web/src/app/admin/assets/page.tsx | 13 +- apps/web/src/app/admin/dashboard/page.tsx | 2 +- .../src/app/admin/groups/[id]/edit/page.tsx | 62 ++-- .../groups/[id]/users/add-users-modal.tsx | 11 +- .../src/app/admin/groups/[id]/users/page.tsx | 11 +- apps/web/src/app/admin/groups/group-form.tsx | 245 ++++++++------ apps/web/src/app/admin/groups/groups-list.tsx | 18 +- apps/web/src/app/admin/groups/page.tsx | 4 +- apps/web/src/app/admin/users/page.tsx | 12 +- .../web/src/components/layout/AdminLayout.tsx | 8 +- apps/web/src/lib/services/actions.ts | 39 +++ apps/web/src/lib/services/authorization.ts | 104 ++++-- apps/web/src/lib/services/groups.ts | 70 ++-- apps/web/src/lib/services/index.ts | 16 +- apps/web/src/lib/services/modules.ts | 39 +++ apps/web/src/lib/services/permissions.ts | 45 ++- apps/web/src/lib/services/users.ts | 29 ++ apps/web/src/server/routers/admin/groups.ts | 48 ++- .../src/server/routers/admin/permissions.ts | 23 +- apps/web/src/server/routers/admin/system.ts | 2 - apps/web/src/server/routers/admin/users.ts | 1 + apps/web/src/server/routers/auth.ts | 50 ++- package.json | 1 + ...0_light_sumo.sql => 0000_silly_iceman.sql} | 56 +++- packages/db/drizzle/meta/0000_snapshot.json | 259 +++++++++++--- packages/db/drizzle/meta/_journal.json | 4 +- packages/db/src/schema/index.ts | 92 ++++- packages/db/src/seeds/permissions.ts | 315 +++++++++++++----- packages/tailwind-config/tailwind.config.ts | 2 +- packages/ui/src/components/table.tsx | 12 +- packages/ui/src/components/tabs.tsx | 4 +- packages/ui/src/styles/globals.css | 6 +- schema.dbml | 47 ++- 33 files changed, 1190 insertions(+), 460 deletions(-) create mode 100644 apps/web/src/lib/services/actions.ts create mode 100644 apps/web/src/lib/services/modules.ts rename packages/db/drizzle/{0000_light_sumo.sql => 0000_silly_iceman.sql} (85%) diff --git a/apps/web/src/app/admin/assets/page.tsx b/apps/web/src/app/admin/assets/page.tsx index d2dd008..e20a776 100644 --- a/apps/web/src/app/admin/assets/page.tsx +++ b/apps/web/src/app/admin/assets/page.tsx @@ -14,6 +14,17 @@ import { SelectValue, } from "@repo/ui"; +/* TODO: A lot to be done here and for assets in general + * - Add pagination URGENT + * - Add search + * - Add filter by file type + * - Add filter by uploaded by + * - Add filter by page + * - Add filter by date + * - Implement preview for images + * - Implement download + */ + export default function AssetsAdminPage() { const notification = useNotification(); const [searchTerm, setSearchTerm] = useState(""); @@ -69,7 +80,7 @@ export default function AssetsAdminPage() { : []; return ( -
+

Asset Management

{/* Search and filter controls */} diff --git a/apps/web/src/app/admin/dashboard/page.tsx b/apps/web/src/app/admin/dashboard/page.tsx index 634c36c..6946277 100644 --- a/apps/web/src/app/admin/dashboard/page.tsx +++ b/apps/web/src/app/admin/dashboard/page.tsx @@ -139,7 +139,7 @@ export default function AdminDashboardPage() { } return ( -
+

Admin Dashboard

Overview of your NextWiki system

diff --git a/apps/web/src/app/admin/groups/[id]/edit/page.tsx b/apps/web/src/app/admin/groups/[id]/edit/page.tsx index d7a3e22..9ec6c1e 100644 --- a/apps/web/src/app/admin/groups/[id]/edit/page.tsx +++ b/apps/web/src/app/admin/groups/[id]/edit/page.tsx @@ -4,6 +4,9 @@ import { redirect } from "next/navigation"; import { dbService } from "~/lib/services"; import GroupForm from "../../group-form"; import { notFound } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@repo/ui"; +import Link from "next/link"; interface EditGroupPageProps { params: { @@ -14,17 +17,23 @@ interface EditGroupPageProps { export async function generateMetadata({ params, }: EditGroupPageProps): Promise { - const group = await dbService.groups.getById(parseInt(params.id)); + const groupId = parseInt((await params).id); + if (isNaN(groupId)) { + return { title: "Invalid Group | NextWiki" }; + } + const group = await dbService.groups.getById(groupId); return { title: `Edit ${group?.name ?? "Group"} | NextWiki`, description: "Edit user group settings and permissions", }; } -export default async function EditGroupPage( - propsPromise: Promise -) { - const { params } = await propsPromise; +export default async function EditGroupPage({ params }: EditGroupPageProps) { + const groupId = parseInt((await params).id); + if (isNaN(groupId)) { + notFound(); + } + const session = await getServerAuthSession(); // Redirect if not logged in @@ -37,33 +46,44 @@ export default async function EditGroupPage( redirect("/"); } - const group = await dbService.groups.getById(parseInt(params.id)); + const group = await dbService.groups.getById(groupId); if (!group) { notFound(); } - // Get all permissions - const permissions = await dbService.permissions.getAll(); + // Get all permissions (already includes module and action names) + const fetchedPermissions = await dbService.permissions.getAll(); + + // Transform permissions to include the synthesized 'name' expected by GroupForm + const permissionsForForm = fetchedPermissions.map((p) => ({ + ...p, + name: `${p.module.name}:${p.resource}:${p.action.name}`, // Synthesize the name + })); // Get group permissions - const groupPermissions = await dbService.groups.getGroupPermissions(group.id); + const groupPermissions = await dbService.groups.getGroupPermissions(groupId); const groupPermissionIds = groupPermissions.map((p) => p.id); // Get module permissions - const modulePermissions = await dbService.groups.getModulePermissions( - group.id - ); - const modulePermissionModules = modulePermissions.map((p) => p.module); + const modulePermissions = + await dbService.groups.getModulePermissions(groupId); + const initialSelectedModuleIds = modulePermissions.map((p) => p.moduleId); // Get action permissions - const actionPermissions = await dbService.groups.getActionPermissions( - group.id - ); - const actionPermissionActions = actionPermissions.map((p) => p.action); + const actionPermissions = + await dbService.groups.getActionPermissions(groupId); + const initialSelectedActionIds = actionPermissions.map((p) => p.actionId); return (
-

Edit Group

+
+ + + +

Edit Group: {group.name}

+

@@ -82,10 +102,10 @@ export default async function EditGroupPage( ? undefined : group.allowUserAssignment, }} - permissions={permissions} + permissions={permissionsForForm} groupPermissions={groupPermissionIds} - groupModulePermissions={modulePermissionModules} - groupActionPermissions={actionPermissionActions} + initialSelectedModuleIds={initialSelectedModuleIds} + initialSelectedActionIds={initialSelectedActionIds} />

diff --git a/apps/web/src/app/admin/groups/[id]/users/add-users-modal.tsx b/apps/web/src/app/admin/groups/[id]/users/add-users-modal.tsx index d8b0701..38b732a 100644 --- a/apps/web/src/app/admin/groups/[id]/users/add-users-modal.tsx +++ b/apps/web/src/app/admin/groups/[id]/users/add-users-modal.tsx @@ -131,7 +131,7 @@ export default function AddUsersModal({
- +
) : filteredUsers && filteredUsers.length > 0 ? ( -
+
- + @@ -159,7 +159,10 @@ export default function AddUsersModal({ {filteredUsers.map((user) => ( - +
Name Email
; }) { + const { id } = await params; const session = await getServerAuthSession(); // Redirect if not logged in @@ -30,7 +31,7 @@ export default async function GroupUsersPage({ redirect("/"); } - const groupId = parseInt(params.id); + const groupId = parseInt(id); const group = await dbService.groups.getById(groupId); if (!group) { @@ -61,10 +62,10 @@ export default async function GroupUsersPage({

{users.length > 0 ? ( -
+
- + @@ -72,7 +73,7 @@ export default async function GroupUsersPage({ {users.map((user) => ( - +
Name Email Actions
{user.name} {user.email} diff --git a/apps/web/src/app/admin/groups/group-form.tsx b/apps/web/src/app/admin/groups/group-form.tsx index 9d4888d..a6864fb 100644 --- a/apps/web/src/app/admin/groups/group-form.tsx +++ b/apps/web/src/app/admin/groups/group-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Button, @@ -31,32 +31,38 @@ interface GroupFormProps { id: number; name: string; description: string | null; - module: string; + moduleId: number; resource: string; - action: string; + actionId: number; + module?: { name: string }; + action?: { name: string }; }[]; groupPermissions?: number[]; - groupModulePermissions?: string[]; - groupActionPermissions?: string[]; + initialSelectedModuleIds?: number[]; + initialSelectedActionIds?: number[]; + initialSelectedModuleNames?: string[]; + initialSelectedActionNames?: string[]; } export default function GroupForm({ group, permissions, groupPermissions = [], - groupModulePermissions = [], - groupActionPermissions = [], + initialSelectedModuleIds, + initialSelectedActionIds, + initialSelectedModuleNames = [], + initialSelectedActionNames = [], }: GroupFormProps) { const router = useRouter(); const [name, setName] = useState(group?.name ?? ""); const [description, setDescription] = useState(group?.description ?? ""); const [selectedPermissions, setSelectedPermissions] = useState(groupPermissions); - const [selectedModules, setSelectedModules] = useState( - groupModulePermissions + const [selectedModules, setSelectedModules] = useState( + initialSelectedModuleIds ?? [] ); - const [selectedActions, setSelectedActions] = useState( - groupActionPermissions + const [selectedActions, setSelectedActions] = useState( + initialSelectedActionIds ?? [] ); const isSystem = group?.isSystem ?? false; @@ -66,13 +72,60 @@ export default function GroupForm({ const trpc = useTRPC(); // Fetch available modules and actions - const { data: availableModules = [] } = useQuery( + const { data: availableModules = [], isLoading: modulesLoading } = useQuery( trpc.admin.permissions.getModules.queryOptions() ); - const { data: availableActions = [] } = useQuery( + const { data: availableActions = [], isLoading: actionsLoading } = useQuery( trpc.admin.permissions.getActions.queryOptions() ); + // Effect to initialize selected IDs from names once data is loaded + useEffect(() => { + if ( + !modulesLoading && + Array.isArray(availableModules) && + initialSelectedModuleNames.length > 0 && + availableModules.length > 0 && + selectedModules.length === 0 + ) { + const moduleNameToIdMap = new Map( + availableModules.map((m) => [m.name, m.id]) + ); + const ids = initialSelectedModuleNames + .map((name) => moduleNameToIdMap.get(name)) + .filter((id): id is number => id !== undefined); + setSelectedModules(ids); + } + }, [ + modulesLoading, + availableModules, + initialSelectedModuleNames, + selectedModules, + ]); + + useEffect(() => { + if ( + !actionsLoading && + Array.isArray(availableActions) && + initialSelectedActionNames.length > 0 && + availableActions.length > 0 && + selectedActions.length === 0 + ) { + const actionNameToIdMap = new Map( + availableActions.map((a) => [a.name, a.id]) + ); + const ids = initialSelectedActionNames + .map((name) => actionNameToIdMap.get(name)) + .filter((id): id is number => id !== undefined); + setSelectedActions(ids); + } + }, [ + actionsLoading, + availableActions, + initialSelectedActionNames, + selectedActions, + ]); + const createGroup = useMutation( trpc.admin.groups.create.mutationOptions({ onSuccess: () => { @@ -162,19 +215,13 @@ export default function GroupForm({ // Update module permissions await addModulePermissions.mutateAsync({ groupId: updatedGroup.id, - permissions: selectedModules.map((module) => ({ - module, - isAllowed: true, - })), + moduleIds: selectedModules, }); // Update action permissions await addActionPermissions.mutateAsync({ groupId: updatedGroup.id, - permissions: selectedActions.map((action) => ({ - action, - isAllowed: true, - })), + actionIds: selectedActions, }); } else { // Create new group @@ -196,19 +243,13 @@ export default function GroupForm({ // Add module permissions await addModulePermissions.mutateAsync({ groupId: newGroup.id, - permissions: selectedModules.map((module) => ({ - module, - isAllowed: true, - })), + moduleIds: selectedModules, }); // Add action permissions await addActionPermissions.mutateAsync({ groupId: newGroup.id, - permissions: selectedActions.map((action) => ({ - action, - isAllowed: true, - })), + actionIds: selectedActions, }); } } catch (error) { @@ -219,10 +260,17 @@ export default function GroupForm({ // Group permissions by module const permissionsByModule = permissions.reduce( (acc, permission) => { - if (!acc[permission.module]) { - acc[permission.module] = []; + if (!permission.module) { + return acc; + } + if (!acc[permission.module.name]) { + acc[permission.module.name] = []; } - acc[permission.module]?.push(permission); + const moduleKey = permission.module.name; + if (!acc[moduleKey]) { + acc[moduleKey] = []; + } + acc[moduleKey]?.push(permission); return acc; }, {} as Record @@ -235,14 +283,14 @@ export default function GroupForm({ // If no actions are selected, all actions are allowed if (selectedActions.length === 0) return true; // Otherwise, check if the action is allowed - return selectedActions.includes(permission.action); + return selectedActions.includes(permission.actionId); } // If modules are selected, check if the module is allowed - if (!selectedModules.includes(permission.module)) return false; + if (!selectedModules.includes(permission.moduleId)) return false; // If actions are selected, check if the action is allowed if ( selectedActions.length > 0 && - !selectedActions.includes(permission.action) + !selectedActions.includes(permission.actionId) ) return false; return true; @@ -275,7 +323,7 @@ export default function GroupForm({ /> {isSystem && ( -
+

This is a system group

You can manage permissions for this group, but the name and @@ -284,7 +332,7 @@ export default function GroupForm({

)} {name === "Administrators" && ( -
+

Administrator Group

The Administrators group has full access to all system features by @@ -297,7 +345,7 @@ export default function GroupForm({

{/* Main permissions area */}
-
+

Permissions

{Object.entries(permissionsByModule).map( @@ -342,7 +390,7 @@ export default function GroupForm({