;
}) {
+ 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 ? (
-
+
-
+
Name
Email
Actions
@@ -72,7 +73,7 @@ export default async function GroupUsersPage({
{users.map((user) => (
-
+
{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..050eb79 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,
@@ -29,34 +29,39 @@ interface GroupFormProps {
};
permissions: {
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 +71,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 +214,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 +242,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 +259,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 +282,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 +322,7 @@ export default function GroupForm({
/>
{isSystem && (
-
+
This is a system group
You can manage permissions for this group, but the name and
@@ -284,7 +331,7 @@ export default function GroupForm({
)}
{name === "Administrators" && (
-
+
Administrator Group
The Administrators group has full access to all system features by
@@ -297,7 +344,7 @@ export default function GroupForm({
{/* Main permissions area */}
-
+
Permissions
{Object.entries(permissionsByModule).map(
@@ -342,7 +389,7 @@ export default function GroupForm({
{permission.description}
@@ -353,18 +400,23 @@ export default function GroupForm({
This permission is not available because:
- {!selectedModules.includes(module) && (
+ {!selectedModules.includes(
+ permission.moduleId
+ ) && (
- • The {module} module is not selected
+ • The{" "}
+ {permission.module?.name ?? "module"}{" "}
+ module is not selected
)}
{selectedActions.length > 0 &&
!selectedActions.includes(
- permission.action
+ permission.actionId
) && (
- • The {permission.action} action is
- not allowed
+ • The{" "}
+ {permission.action?.name ?? "action"}{" "}
+ action is not allowed
)}
@@ -385,80 +437,64 @@ export default function GroupForm({
{/* Permissions sidebar */}
{/* Modules */}
-
+
Module Permissions
-
+
Select which modules this group can access. If no modules are
selected, all modules are allowed.
- {availableModules.map((module: string) => (
-
- ) => {
- if (e.target.checked) {
- setSelectedModules([...selectedModules, module]);
- } else {
- setSelectedModules(
- selectedModules.filter((m) => m !== module)
- );
- // Remove permissions for this module when it's unselected
- setSelectedPermissions(
- selectedPermissions.filter(
- (id) =>
- !permissions.find(
- (p) => p.id === id && p.module === module
- )
- )
- );
- }
- }}
- disabled={name === "Administrators"}
- />
- {module}
-
- ))}
+ {Array.isArray(availableModules) &&
+ availableModules.map((module) => (
+
+ ) => {
+ if (e.target.checked) {
+ setSelectedModules([...selectedModules, module.id]);
+ } else {
+ setSelectedModules(
+ selectedModules.filter((id) => id !== module.id)
+ );
+ }
+ }}
+ disabled={name === "Administrators"}
+ />
+ {module.name}
+
+ ))}
{/* Actions */}
-
+
Action Permissions
-
+
Select which actions this group can perform. If no actions are
selected, all actions are allowed.
- {availableActions.map((action: string) => (
-
- ) => {
- if (e.target.checked) {
- setSelectedActions([...selectedActions, action]);
- } else {
- setSelectedActions(
- selectedActions.filter((a) => a !== action)
- );
- // Remove permissions for this action when it's unselected
- setSelectedPermissions(
- selectedPermissions.filter(
- (id) =>
- !permissions.find(
- (p) => p.id === id && p.action === action
- )
- )
- );
- }
- }}
- disabled={name === "Administrators"}
- />
- {action}
-
- ))}
+ {Array.isArray(availableActions) &&
+ availableActions.map((action) => (
+
+ ) => {
+ if (e.target.checked) {
+ setSelectedActions([...selectedActions, action.id]);
+ } else {
+ setSelectedActions(
+ selectedActions.filter((id) => id !== action.id)
+ );
+ }
+ }}
+ disabled={name === "Administrators"}
+ />
+ {action.name}
+
+ ))}
diff --git a/apps/web/src/app/admin/groups/groups-list.tsx b/apps/web/src/app/admin/groups/groups-list.tsx
index 0483ec3..fd14b1b 100644
--- a/apps/web/src/app/admin/groups/groups-list.tsx
+++ b/apps/web/src/app/admin/groups/groups-list.tsx
@@ -48,7 +48,7 @@ export default function GroupsList({ groups: initialGroups }: GroupsListProps) {
};
return (
-
+
@@ -83,11 +83,17 @@ export default function GroupsList({ groups: initialGroups }: GroupsListProps) {
-
-
-
-
-
+ {group.allowUserAssignment && (
+
+
+
+
+
+ )}
diff --git a/apps/web/src/app/admin/groups/page.tsx b/apps/web/src/app/admin/groups/page.tsx
index 8f92e67..d951c7b 100644
--- a/apps/web/src/app/admin/groups/page.tsx
+++ b/apps/web/src/app/admin/groups/page.tsx
@@ -43,7 +43,7 @@ export default async function GroupsPage() {
Groups Management
-
+
User Groups
{canCreateGroups && (
@@ -52,7 +52,7 @@ export default async function GroupsPage() {
)}
-
+
User groups allow you to organize users and assign permissions to them
collectively. Each group can have multiple permissions, and users can
belong to multiple groups.
diff --git a/apps/web/src/app/admin/not-found.tsx b/apps/web/src/app/admin/not-found.tsx
index 96ebf9d..231a2bc 100644
--- a/apps/web/src/app/admin/not-found.tsx
+++ b/apps/web/src/app/admin/not-found.tsx
@@ -7,9 +7,11 @@ export default function NotFound() {
const router = useRouter();
return (
-
+
Admin Page Not Found
- router.back()}>Go back
+ router.back()}>
+ Go back
+
);
}
diff --git a/apps/web/src/app/admin/permissions/page.tsx b/apps/web/src/app/admin/permissions/page.tsx
index d632140..f164f9f 100644
--- a/apps/web/src/app/admin/permissions/page.tsx
+++ b/apps/web/src/app/admin/permissions/page.tsx
@@ -3,6 +3,7 @@ import { getServerAuthSession } from "~/lib/auth";
import { redirect } from "next/navigation";
import { dbService } from "~/lib/services";
import PermissionsTable from "./permissions-table";
+import { PermissionsActions } from "./permissions-actions";
export const metadata: Metadata = {
title: "Admin - Permissions | NextWiki",
@@ -28,6 +29,10 @@ export default async function PermissionsPage() {
Permissions Management
+
+
+
+
System Permissions
diff --git a/apps/web/src/app/admin/permissions/permissions-actions.tsx b/apps/web/src/app/admin/permissions/permissions-actions.tsx
new file mode 100644
index 0000000..2f35482
--- /dev/null
+++ b/apps/web/src/app/admin/permissions/permissions-actions.tsx
@@ -0,0 +1,270 @@
+"use client";
+
+import { useState } from "react";
+import { Button, Modal } from "@repo/ui";
+import { toast } from "sonner";
+import { useTRPC } from "~/server/client";
+import { logger } from "@repo/logger";
+import { useMutation } from "@tanstack/react-query";
+import { AlertTriangle, CheckCircle, XCircle } from "lucide-react";
+
+// Define a type for the validation status
+type ValidationStatus =
+ | {
+ isValid: true;
+ missingCount: number;
+ extrasCount: number;
+ mismatchedCount: number;
+ error?: false; // Explicitly indicate no error
+ }
+ | {
+ isValid: false;
+ missingCount: number;
+ extrasCount: number;
+ mismatchedCount: number;
+ error?: false; // Explicitly indicate no error
+ }
+ | {
+ isValid: false;
+ error: true; // Indicate validation failed due to an error
+ message?: string;
+ }
+ | undefined; // Initial state
+
+export function PermissionsActions() {
+ const trpc = useTRPC();
+ const [validationStatus, setValidationStatus] =
+ useState(undefined);
+ const [showFixModal, setShowFixModal] = useState(null); // true for removeExtras, false otherwise, null if closed
+
+ const validateMutation = useMutation(
+ trpc.admin.permissions.validate.mutationOptions({
+ onSuccess: (data) => {
+ // Explicitly create the correct ValidationStatus variant
+ const newStatus: ValidationStatus = data.isValid
+ ? {
+ isValid: true,
+ missingCount: data.missingCount,
+ extrasCount: data.extrasCount,
+ mismatchedCount: data.mismatchedCount,
+ error: false, // Ensure error is explicitly false
+ }
+ : {
+ isValid: false,
+ missingCount: data.missingCount,
+ extrasCount: data.extrasCount,
+ mismatchedCount: data.mismatchedCount,
+ error: false, // Ensure error is explicitly false
+ };
+ setValidationStatus(newStatus); // Store the validation result
+
+ if (newStatus.isValid) {
+ toast.success("Permissions Validation Successful", {
+ description: "Registry and database permissions match.",
+ });
+ } else {
+ toast.warning("Permissions Validation Issues Found", {
+ description: `Missing: ${newStatus.missingCount}, Extras: ${newStatus.extrasCount}, Mismatched: ${newStatus.mismatchedCount}. Use 'Fix Permissions' to resolve.`,
+ });
+ }
+ logger.log("Validation Result:", data);
+ },
+ onError: (error) => {
+ setValidationStatus({
+ isValid: false,
+ error: true,
+ message: error.message,
+ }); // Store error state
+ toast.error("Permissions Validation Failed", {
+ description: error.message,
+ });
+ logger.error("Validation Error:", error);
+ },
+ })
+ );
+
+ const fixMutation = useMutation(
+ trpc.admin.permissions.fix.mutationOptions({
+ // onMutate is handled by modal state now
+ onSuccess: (data) => {
+ toast.success("Permissions Fix Successful", {
+ description: `Added: ${data.added}, Updated: ${data.updated}, Removed: ${data.removed}. Re-validating...`,
+ });
+ logger.log("Fix Result:", data);
+ },
+ onError: (error) => {
+ toast.error("Permissions Fix Failed", {
+ description: error.message,
+ });
+ logger.error("Fix Error:", error);
+ },
+ onSettled: () => {
+ setShowFixModal(null); // Close modal on settle
+ // Re-run validation after fix attempt
+ validateMutation.mutate();
+ },
+ })
+ );
+
+ const handleValidate = () => {
+ setValidationStatus(undefined); // Reset status before validating
+ validateMutation.mutate();
+ };
+
+ // This function now opens the modal
+ const handleInitiateFix = (removeExtras = false) => {
+ setShowFixModal(removeExtras);
+ };
+
+ // This function confirms the fix from the modal
+ const handleConfirmFix = () => {
+ if (showFixModal === null) return; // Should not happen, but safeguard
+ fixMutation.mutate({ removeExtras: showFixModal });
+ };
+
+ const handleCloseModal = () => {
+ if (!fixMutation.isPending) {
+ setShowFixModal(null);
+ }
+ };
+
+ // Determine if fix actions should be enabled
+ const canFix =
+ validationStatus !== undefined &&
+ !validationStatus.isValid &&
+ !validationStatus.error;
+
+ return (
+ <>
+
+
+ {validateMutation.isPending
+ ? "Validating..."
+ : "Validate Permissions"}
+
+
+ {/* Conditionally render validation status */}
+ {validationStatus && !validateMutation.isPending && (
+
+ {validationStatus.isValid ? (
+ <>
+
+
+ Permissions are valid.
+
+ >
+ ) : validationStatus.error ? (
+ <>
+
+
+ Validation failed. Check logs.
+
+ >
+ ) : (
+ <>
+
+
+ Issues found: {validationStatus.missingCount} Missing,{" "}
+ {validationStatus.extrasCount} Extras,{" "}
+ {validationStatus.mismatchedCount} Mismatched.
+
+ >
+ )}
+
+ )}
+
+
+ handleInitiateFix(false)}
+ disabled={
+ !canFix || fixMutation.isPending || validateMutation.isPending
+ }
+ variant="outlined"
+ color="warning"
+ >
+ Fix Permissions (Keep Extras)
+
+ handleInitiateFix(true)}
+ disabled={
+ !canFix || fixMutation.isPending || validateMutation.isPending
+ }
+ variant="outlined"
+ color="error"
+ >
+ Fix Permissions (Remove Extras)
+
+
+
+ Validate checks registry against DB. Fix actions are only available if
+ validation fails. 'Remove Extras' deletes DB permissions not in the
+ code registry (potentially impactful).
+
+
+
+ {/* Confirmation Modal */}
+ {showFixModal !== null && (
+
+
+
+
+
Confirm Permission Fix
+
+
+ You are about to modify the permissions stored in the database
+ based on the code registry.
+ {showFixModal && (
+
+ {" "}
+ This includes removing extra permissions found in the database
+ but not defined in the code.
+
+ )}
+ {!showFixModal && (
+
+ {" "}
+ This will add missing permissions and update mismatched ones,
+ but keep any extra permissions found in the database.
+
+ )}
+
+ This action can impact user access if not done carefully. Are you
+ sure you want to proceed?
+
+
+
+ Cancel
+
+
+ {fixMutation.isPending ? "Fixing..." : "Confirm Fix"}
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/app/admin/permissions/permissions-table.tsx b/apps/web/src/app/admin/permissions/permissions-table.tsx
index 3a01be6..72296ab 100644
--- a/apps/web/src/app/admin/permissions/permissions-table.tsx
+++ b/apps/web/src/app/admin/permissions/permissions-table.tsx
@@ -4,21 +4,36 @@ import { useState } from "react";
interface Permission {
id: number;
- name: string;
description: string | null;
module: string;
action: string;
+ resource: string;
}
interface PermissionsTableProps {
- permissions: Permission[];
+ permissions: {
+ id: number;
+ description: string | null;
+ resource: string;
+ action: { name: string };
+ module: { name: string };
+ }[];
}
export default function PermissionsTable({
- permissions,
+ permissions: rawPermissions,
}: PermissionsTableProps) {
const [filter, setFilter] = useState("");
+ // Map rawPermissions to the local Permission structure
+ const permissions: Permission[] = rawPermissions.map((p) => ({
+ id: p.id,
+ description: p.description,
+ module: p.module.name,
+ action: p.action.name,
+ resource: p.resource,
+ }));
+
// Group permissions by module
const groupedPermissions = permissions.reduce(
(acc, permission) => {
@@ -41,11 +56,9 @@ export default function PermissionsTable({
module.toLowerCase().includes(lowerCaseFilter) ||
modulePermissions?.some(
(p) =>
- p.name.toLowerCase().includes(lowerCaseFilter) ||
+ p.resource.toLowerCase().includes(lowerCaseFilter) ||
(p.description?.toLowerCase() || "").includes(lowerCaseFilter) ||
- p.name.toLowerCase().includes(filter.toLowerCase()) ||
- (p.description?.toLowerCase() || "").includes(filter.toLowerCase()) ||
- p.action.toLowerCase().includes(filter.toLowerCase())
+ p.action.toLowerCase().includes(lowerCaseFilter)
)
);
});
@@ -55,7 +68,7 @@ export default function PermissionsTable({
setFilter(e.target.value)}
@@ -74,7 +87,7 @@ export default function PermissionsTable({
- Name
+ Resource
Action
Description
@@ -84,7 +97,9 @@ export default function PermissionsTable({
?.filter(
(p) =>
!filter ||
- p.name.toLowerCase().includes(filter.toLowerCase()) ||
+ p.resource
+ .toLowerCase()
+ .includes(filter.toLowerCase()) ||
(p.description?.toLowerCase() || "").includes(
filter.toLowerCase()
) ||
@@ -96,7 +111,7 @@ export default function PermissionsTable({
className="border-background-level2 border-b"
>
- {permission.name}
+ {permission.resource}
{permission.action}
diff --git a/apps/web/src/app/admin/settings/components/category-settings.tsx b/apps/web/src/app/admin/settings/components/category-settings.tsx
new file mode 100644
index 0000000..4a19e5a
--- /dev/null
+++ b/apps/web/src/app/admin/settings/components/category-settings.tsx
@@ -0,0 +1,448 @@
+"use client";
+
+import { useState } from "react";
+import { useTRPC } from "~/server/client";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Input,
+ Checkbox,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ Button,
+ Badge,
+ Skeleton,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@repo/ui";
+import { format } from "date-fns";
+import type { SettingCategory } from "@repo/types";
+import {
+ Clock,
+ History,
+ HelpCircle,
+ RotateCw,
+ Save,
+ EyeOff,
+} from "lucide-react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+interface CategorySettingsProps {
+ category: SettingCategory;
+ isLoading: boolean;
+}
+
+export function CategorySettings({
+ category,
+ isLoading,
+}: CategorySettingsProps) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const { data: settings, isLoading: isLoadingSettings } = useQuery(
+ trpc.admin.settings.getByCategory.queryOptions(
+ { category },
+ { enabled: !isLoading }
+ )
+ );
+
+ // State for edited values
+ const [editedValues, setEditedValues] = useState>({});
+ const [historyKey, setHistoryKey] = useState(null);
+
+ const byCategoryQueryKey = trpc.admin.settings.getByCategory.queryKey();
+ const getAllQueryKey = trpc.admin.settings.getAll.queryKey();
+
+ // Update setting mutation
+ const updateSetting = useMutation(
+ trpc.admin.settings.update.mutationOptions({
+ onSuccess: () => {
+ // Clear the edited value and refetch
+ setEditedValues((prev) => {
+ const updated = { ...prev };
+ delete updated[updateSetting.variables?.key ?? ""];
+ return updated;
+ });
+
+ // Invalidate queries to refresh data
+ queryClient.invalidateQueries({ queryKey: byCategoryQueryKey });
+ queryClient.invalidateQueries({ queryKey: getAllQueryKey });
+ },
+ })
+ );
+
+ // Reset setting mutation
+ const resetSetting = useMutation(
+ trpc.admin.settings.reset.mutationOptions({
+ onSuccess: () => {
+ // Invalidate queries to refresh data
+ queryClient.invalidateQueries({ queryKey: byCategoryQueryKey });
+ queryClient.invalidateQueries({ queryKey: getAllQueryKey });
+ },
+ })
+ );
+
+ // Get setting history
+ const { data: history, isLoading: isLoadingHistory } = useQuery(
+ trpc.admin.settings.getHistory.queryOptions(
+ { key: historyKey! },
+ { enabled: !!historyKey }
+ )
+ );
+
+ const handleValueChange = (key: string, value: any) => {
+ setEditedValues((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ };
+
+ const handleSave = (key: string) => {
+ const value = editedValues[key];
+ updateSetting.mutate({
+ key,
+ value,
+ reason: `Updated from admin settings page`,
+ });
+ };
+
+ const handleReset = (key: string) => {
+ resetSetting.mutate({
+ key,
+ reason: `Reset to default from admin settings page`,
+ });
+ };
+
+ const renderSettingInput = (setting: any) => {
+ const key = setting.key;
+ const value = key in editedValues ? editedValues[key] : setting.value;
+ const type = setting.meta.type;
+ const isEdited = key in editedValues;
+ const isSecret = setting.meta.isSecret;
+
+ switch (type) {
+ case "string":
+ return (
+
+
handleValueChange(key, e.target.value)}
+ className="max-w-sm"
+ />
+ {isSecret && (
+
+
+
+
+
+
+
+ This is a sensitive setting
+
+
+ )}
+
+ );
+
+ case "number":
+ return (
+ handleValueChange(key, Number(e.target.value))}
+ className="max-w-xs"
+ />
+ );
+
+ case "boolean":
+ return (
+ handleValueChange(key, e.target.checked)}
+ />
+ );
+
+ case "select":
+ return (
+ handleValueChange(key, value)}
+ >
+
+
+
+
+ {setting.meta.options.map((option: string) => (
+
+ {option}
+
+ ))}
+
+
+ );
+
+ case "json":
+ // Display JSON as a string in a textarea
+ return (
+
+
+ );
+
+ default:
+ return Unsupported type: {type}
;
+ }
+ };
+
+ if (isLoading || isLoadingSettings) {
+ return (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+
+
+
+ ))}
+
+ );
+ }
+
+ if (!settings || settings.length === 0) {
+ return No settings found for this category.
;
+ }
+
+ return (
+
+
+
+
+ Setting
+ Value
+ Actions
+
+
+
+ {settings.map((setting) => {
+ const key = setting.key;
+ const isEdited = key in editedValues;
+ const isUpdating =
+ updateSetting.isPending && updateSetting.variables?.key === key;
+ const isResetting =
+ resetSetting.isPending && resetSetting.variables?.key === key;
+ const requiresRestart = setting.meta.requiresRestart;
+
+ return (
+
+
+
+
+ {key}
+
+
+
+
+
+
+
+
+ {setting.meta.description}
+
+
+
+ {requiresRestart && (
+
+ Requires restart
+
+ )}
+
+
+ {setting.meta.description}
+
+
+
+
+ {renderSettingInput(setting)}
+ {isEdited && (
+
+ Modified - Save to apply changes
+
+ )}
+
+
+
+
handleSave(key)}
+ disabled={!isEdited || isUpdating || isResetting}
+ className="h-8"
+ >
+ {isUpdating ? (
+ <>
+
+ Saving...
+ >
+ ) : (
+ <>
+
+ Save
+ >
+ )}
+
+
+
handleReset(key)}
+ disabled={isUpdating || isResetting}
+ className="h-8"
+ >
+ {isResetting ? (
+ <>
+
+ Resetting...
+ >
+ ) : (
+ <>
+
+ Reset
+ >
+ )}
+
+
+
!open && setHistoryKey(null)}
+ >
+
+ setHistoryKey(key)}
+ >
+
+
+
+
+
+ Setting History
+
+ History of changes for setting: {key}
+
+
+
+ {isLoadingHistory ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : history && history.length > 0 ? (
+
+ {history.map((entry) => (
+
+
+
+
+
+ {format(
+ new Date(entry.changedAt),
+ "MMM d, yyyy h:mm a"
+ )}
+
+
+
+ {entry.changedById ? (
+
+ User ID: {entry.changedById}
+
+ ) : (
+
+ System
+
+ )}
+
+
+
+ {entry.changeReason && (
+
+ Reason: {entry.changeReason}
+
+ )}
+
+
+ Previous value:
+
+
+ {typeof entry.previousValue === "object"
+ ? JSON.stringify(
+ entry.previousValue,
+ null,
+ 2
+ )
+ : String(entry.previousValue)}
+
+
+ ))}
+
+ ) : (
+
+ No history available for this setting
+
+ )}
+
+
+ setHistoryKey(null)}
+ >
+ Close
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/admin/settings/components/settings-page.tsx b/apps/web/src/app/admin/settings/components/settings-page.tsx
new file mode 100644
index 0000000..6459219
--- /dev/null
+++ b/apps/web/src/app/admin/settings/components/settings-page.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { useState } from "react";
+import { useTRPC } from "~/server/client";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+ Button,
+ Alert,
+ AlertTitle,
+ AlertDescription,
+} from "@repo/ui";
+import { CategorySettings } from "./category-settings";
+import { Loader2 } from "lucide-react";
+import type { SettingCategory } from "@repo/types";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { PageHeader } from "~/components/layout/page-header";
+
+// Array of all setting categories
+const CATEGORIES: SettingCategory[] = [
+ "general",
+ "auth",
+ "appearance",
+ "editor",
+ "search",
+ "advanced",
+];
+
+// Map of category names to display names
+const CATEGORY_NAMES: Record = {
+ general: "General",
+ auth: "Authentication",
+ appearance: "Appearance",
+ editor: "Editor",
+ search: "Search",
+ advanced: "Advanced",
+};
+
+// Map of category names to descriptions
+const CATEGORY_DESCRIPTIONS: Record = {
+ general: "Basic wiki settings like site name and description",
+ auth: "User authentication and registration settings",
+ appearance: "Visual appearance and theme settings",
+ editor: "Content editor behavior and defaults",
+ search: "Search functionality and performance",
+ advanced: "Advanced configuration and storage settings",
+};
+
+export function SettingsPage() {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+
+ const [activeCategory, setActiveCategory] =
+ useState("general");
+ const { data: allSettings, isLoading: isLoadingAll } = useQuery(
+ trpc.admin.settings.getAll.queryOptions()
+ );
+
+ const getAllQueryKey = trpc.admin.settings.getAll.queryKey();
+
+ // Initialize settings when first loaded
+ const initializeSettings = useMutation(
+ trpc.admin.settings.initialize.mutationOptions({
+ onSuccess: () => {
+ // Refetch settings after initialization
+ queryClient.invalidateQueries({ queryKey: getAllQueryKey });
+ },
+ })
+ );
+
+ // Test if we have no settings yet
+ const noSettings =
+ !isLoadingAll && (!allSettings || Object.keys(allSettings).length === 0);
+
+ return (
+
+
initializeSettings.mutate()}
+ disabled={initializeSettings.isPending || !noSettings}
+ >
+ {initializeSettings.isPending ? (
+ <>
+
+ Initializing...
+ >
+ ) : (
+ "Initialize Default Settings"
+ )}
+
+ }
+ />
+
+ {noSettings && (
+
+ No settings found
+
+ No settings have been initialized yet. Click the button above to
+ create the default settings.
+
+
+ )}
+
+
+
+ System Settings
+ Configure your wiki system settings
+
+
+
+ setActiveCategory(value as SettingCategory)
+ }
+ >
+
+ {CATEGORIES.map((category) => (
+
+ {CATEGORY_NAMES[category]}
+
+ ))}
+
+
+ {CATEGORIES.map((category) => (
+
+
+
+ {CATEGORY_NAMES[category]} Settings
+
+
+ {CATEGORY_DESCRIPTIONS[category]}
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/admin/settings/page.tsx b/apps/web/src/app/admin/settings/page.tsx
new file mode 100644
index 0000000..dd2f946
--- /dev/null
+++ b/apps/web/src/app/admin/settings/page.tsx
@@ -0,0 +1,15 @@
+import { Metadata } from "next";
+import { SettingsPage } from "./components/settings-page";
+
+export const metadata: Metadata = {
+ title: "Settings | Admin | NextWiki",
+ description: "Manage system settings for NextWiki",
+};
+
+export default function AdminSettingsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/app/admin/users/page.tsx b/apps/web/src/app/admin/users/page.tsx
index 937cbb9..4eb4923 100644
--- a/apps/web/src/app/admin/users/page.tsx
+++ b/apps/web/src/app/admin/users/page.tsx
@@ -11,7 +11,7 @@ import {
TabsTrigger,
} from "@repo/ui";
import { useTRPC } from "~/server/client";
-import { useQuery, useMutation } from "@tanstack/react-query";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
usePermissions,
@@ -26,10 +26,14 @@ export default function AdminUsersPage() {
// Fetch users
const trpc = useTRPC();
+ const queryClient = useQueryClient();
+
const { data: users, isLoading: usersLoading } = useQuery(
trpc.admin.users.getAll.queryOptions()
);
+ const usersQueryKey = trpc.admin.users.getAll.queryKey();
+
// Infer user type from the fetched data
type FetchedUser = NonNullable[number];
@@ -131,8 +135,9 @@ export default function AdminUsersPage() {
)
);
}
+
// Optional: Refetch user data after successful save
- // await utils.users.getAll.invalidate(); // Or invalidate specific user
+ queryClient.invalidateQueries({ queryKey: usersQueryKey });
// handleUserSelect(updatedUserData); // Update selectedUser if data structure changes significantly post-save
} catch (error) {
// Errors are handled by individual mutation's onError, but you could add general handling here
@@ -141,7 +146,7 @@ export default function AdminUsersPage() {
};
return (
-
+
User Management
@@ -244,7 +249,6 @@ export default function AdminUsersPage() {
Details
Groups
- {/* Permissions */}
diff --git a/apps/web/src/app/admin/wiki/page.tsx b/apps/web/src/app/admin/wiki/page.tsx
new file mode 100644
index 0000000..06daa41
--- /dev/null
+++ b/apps/web/src/app/admin/wiki/page.tsx
@@ -0,0 +1,466 @@
+"use client";
+
+import React, { useState, useMemo, useCallback } from "react";
+import Link from "next/link";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Button,
+ Card,
+ Badge,
+ Input,
+ Skeleton,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ Modal,
+} from "@repo/ui";
+import {
+ useReactTable,
+ getCoreRowModel,
+ flexRender,
+ ColumnDef,
+ SortingState,
+ getSortedRowModel,
+} from "@tanstack/react-table";
+import { useTRPC } from "~/server/client";
+import type { AppRouter } from "~/server/routers";
+import type { inferProcedureOutput, inferProcedureInput } from "@trpc/server";
+import {
+ useInfiniteQuery,
+ useMutation,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { useInView } from "react-intersection-observer";
+import { toast } from "sonner";
+import {
+ PencilIcon,
+ TrashIcon,
+ EyeIcon,
+ EllipsisVerticalIcon,
+} from "@heroicons/react/24/outline";
+import { useDebounce } from "~/lib/hooks/useDebounce";
+
+// Infer types from procedures
+type AdminWikiListOutput = inferProcedureOutput<
+ AppRouter["admin"]["wiki"]["list"]
+>;
+// Base type reflecting server output (Dates)
+type PageItemServer = AdminWikiListOutput["items"][number];
+type AdminWikiDeleteInput = inferProcedureInput;
+
+// Client-Side Type reflecting serialized dates
+type PageItemClient = Omit<
+ PageItemServer,
+ "createdAt" | "updatedAt" | "lockExpiresAt"
+> & {
+ createdAt: string | null;
+ updatedAt: string | null;
+ lockExpiresAt: string | null; // Assuming lockExpiresAt is also a Date serialized to string
+};
+
+export default function AdminWikiPage() {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const [sorting, setSorting] = useState([]);
+ const [searchTerm, setSearchTerm] = useState("");
+ const debouncedSearchTerm = useDebounce(searchTerm, 300);
+
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ // Use Client type for state
+ const [pageToDelete, setPageToDelete] = useState(null);
+
+ // Define query inputs based on state
+ const queryInput = useMemo(
+ () => ({
+ limit: 20,
+ sortBy: sorting[0]?.id as "title" | "path" | "updatedAt" | "createdAt",
+ sortOrder: (sorting[0]
+ ? sorting[0].desc
+ ? "desc"
+ : "asc"
+ : undefined) as "asc" | "desc" | undefined,
+ search: debouncedSearchTerm,
+ }),
+ [sorting, debouncedSearchTerm]
+ );
+
+ // Define infinite query options using the helper
+ const listQueryOptions = trpc.admin.wiki.list.infiniteQueryOptions(
+ queryInput,
+ {
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ }
+ );
+
+ const {
+ data,
+ error,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading,
+ } = useInfiniteQuery(listQueryOptions);
+
+ // Flatten the pages data
+ const flatData = useMemo(
+ // flatData will have string dates due to serialization
+ () => data?.pages?.flatMap((page) => page.items) ?? [],
+ [data]
+ );
+
+ const { ref, inView } = useInView({ threshold: 0.5 });
+
+ React.useEffect(() => {
+ if (inView && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [inView, fetchNextPage, hasNextPage, isFetchingNextPage]);
+
+ // Define mutation options using the helper
+ const deleteMutationOptions = trpc.wiki.delete.mutationOptions({
+ onSuccess: (data, variables: AdminWikiDeleteInput) => {
+ const deletedTitle =
+ flatData.find((p) => p.id === variables.id)?.title ||
+ `ID: ${variables.id}`;
+ toast.success(`Page "${deletedTitle}" deleted successfully.`);
+ queryClient.invalidateQueries({
+ queryKey: trpc.admin.wiki.list.queryKey(),
+ });
+ setPageToDelete(null);
+ },
+ // Use 'any' or import TRPCClientErrorLike if preferred
+ onError: (error) => {
+ toast.error(`Failed to delete page: ${error.message}`);
+ console.error("Delete page error:", error);
+ },
+ onSettled: () => {
+ setShowDeleteDialog(false);
+ },
+ });
+
+ const deletePageMutation = useMutation(deleteMutationOptions);
+
+ // Use Client type for callback parameter
+ const handleDeleteClick = useCallback((page: PageItemClient) => {
+ setPageToDelete(page);
+ setShowDeleteDialog(true);
+ }, []);
+
+ const confirmDelete = () => {
+ if (pageToDelete) {
+ // pageToDelete is PageItemClient, pass its ID
+ deletePageMutation.mutate({ id: pageToDelete.id });
+ }
+ };
+
+ // Use PageItemClient for Column Definitions
+ const columns = useMemo[]>(
+ () => [
+ {
+ accessorKey: "title",
+ header: ({ column }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ Title
+
+ ),
+ // row.original is now PageItemClient
+ cell: ({ row }) => (
+ {row.original.title}
+ ),
+ },
+ {
+ accessorKey: "path",
+ header: ({ column }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ Path
+
+ ),
+ cell: ({ row }) => (
+
+ {row.original.path}
+
+ ),
+ },
+ {
+ accessorKey: "isPublished",
+ header: "Status",
+ cell: ({ row }) => (
+
+ {row.original.isPublished ? "Published" : "Draft"}
+
+ ),
+ enableSorting: false,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ Updated
+
+ ),
+ // Display string date or relative date
+ cell: ({ row }) => (
+
+ {row.original.updatedAtRelative} by{" "}
+ {row.original.updatedBy?.name || "N/A"}
+
+ ),
+ },
+ {
+ id: "actions",
+ header: () => Actions
,
+ cell: ({ row }) => {
+ // row.original is PageItemClient
+ const page = row.original;
+ const deleteHandler = useCallback(
+ () => handleDeleteClick(page),
+ [page]
+ );
+
+ return (
+
+
+
+
+ Open menu
+
+
+
+
+
+
+ View
+
+
+
+
+ Edit
+
+
+
+ Delete
+
+
+
+
+ );
+ },
+ enableSorting: false,
+ },
+ ],
+ [handleDeleteClick]
+ );
+
+ // Initialize the table instance
+ const table = useReactTable({
+ // Assert flatData type here for safety
+ data: flatData as PageItemClient[],
+ columns,
+ state: {
+ sorting,
+ },
+ onSortingChange: setSorting,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ manualPagination: true,
+ manualSorting: true,
+ });
+
+ if (error) {
+ return (
+
+ Error loading pages: {error.message}
+
+ );
+ }
+
+ // JSX Rendering remains largely the same
+ return (
+
+
+
+
Manage Wiki Pages
+
+ View, edit, and manage all wiki pages.
+
+
+
+ setSearchTerm(e.target.value)}
+ className="max-w-xs"
+ />
+
+ Create Page
+
+
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ ))}
+
+ ))}
+
+
+ {isLoading && !data ? (
+ [...Array(5)].map((_, i) => (
+
+ {columns.map((col, colIndex) => (
+
+
+
+ ))}
+
+ ))
+ ) : table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No pages found{" "}
+ {debouncedSearchTerm ? `for "${debouncedSearchTerm}"` : ""}.
+
+
+ )}
+ {isFetchingNextPage && (
+
+
+
+ Loading more...
+
+
+
+ )}
+
+
+
+
+ {!isLoading && !hasNextPage && flatData.length > 0 && (
+
+ End of list.
+
+ )}
+ {!isLoading &&
+ !hasNextPage &&
+ flatData.length === 0 &&
+ !debouncedSearchTerm && (
+
+ No pages exist yet. Create one!
+
+ )}
+
+
+ {showDeleteDialog && pageToDelete && (
+
setShowDeleteDialog(false)}
+ size="sm"
+ showCloseButton={true}
+ className="p-6"
+ >
+
+
Confirm Deletion
+
+ Are you sure you want to delete the page
+
+ {" "}
+ "{pageToDelete.title}"
+ {" "}
+ ({pageToDelete.path})? This action is permanent and cannot be
+ undone.
+
+
+ setShowDeleteDialog(false)}
+ >
+ Cancel
+
+
+ {deletePageMutation.isPending ? "Deleting..." : "Delete"}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/auth/LoginForm.tsx b/apps/web/src/components/auth/LoginForm.tsx
index a06d778..9154bbf 100644
--- a/apps/web/src/components/auth/LoginForm.tsx
+++ b/apps/web/src/components/auth/LoginForm.tsx
@@ -7,6 +7,7 @@ import { signIn } from "next-auth/react";
import { Alert, AlertDescription } from "@repo/ui";
import { Button } from "@repo/ui";
import { Input } from "@repo/ui";
+import { useSetting } from "~/lib/hooks/use-settings";
// Create a separate component to read searchParams to work with Suspense
function RegistrationSuccessMessage() {
@@ -32,6 +33,7 @@ export function LoginForm() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
+ const { value: allowRegistration } = useSetting("auth.allowRegistration");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -127,14 +129,16 @@ export function LoginForm() {
Forgot your password?
-
-
- Create an account
-
-
+ {allowRegistration && (
+
+
+ Create an account
+
+
+ )}
diff --git a/apps/web/src/components/layout/AdminLayout.tsx b/apps/web/src/components/layout/AdminLayout.tsx
index f433400..0441959 100644
--- a/apps/web/src/components/layout/AdminLayout.tsx
+++ b/apps/web/src/components/layout/AdminLayout.tsx
@@ -4,6 +4,43 @@ import { ReactNode, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { env } from "~/env";
+import { cn, ScrollArea } from "@repo/ui";
+import {
+ HomeIcon,
+ BookOpenIcon,
+ UsersIcon,
+ ShieldCheckIcon,
+ UserGroupIcon,
+ PhotoIcon,
+ CogIcon,
+ BeakerIcon,
+ ArrowLeftOnRectangleIcon,
+ ChevronDoubleLeftIcon,
+ ChevronDoubleRightIcon,
+} from "@heroicons/react/24/outline";
+
+interface NavItem {
+ href: string;
+ label: string;
+ icon: React.ComponentType<{ className?: string }>;
+ condition?: boolean;
+}
+
+const navigationItems: NavItem[] = [
+ { href: "/admin/dashboard", label: "Dashboard", icon: HomeIcon },
+ { href: "/admin/wiki", label: "Wiki Pages", icon: BookOpenIcon },
+ { href: "/admin/users", label: "Users", icon: UsersIcon },
+ { href: "/admin/permissions", label: "Permissions", icon: ShieldCheckIcon },
+ { href: "/admin/groups", label: "Groups", icon: UserGroupIcon },
+ { href: "/admin/assets", label: "Assets", icon: PhotoIcon },
+ {
+ href: "/admin/example",
+ label: "Permission Example",
+ icon: BeakerIcon,
+ condition: env.NEXT_PUBLIC_NODE_ENV === "development",
+ },
+ { href: "/admin/settings", label: "Settings", icon: CogIcon },
+];
interface AdminLayoutProps {
children: ReactNode;
@@ -41,258 +78,53 @@ export function AdminLayout({ children }: AdminLayoutProps) {
className="hover:bg-background-level2 text-text-secondary rounded-md p-1"
>
{collapsed ? (
-
-
-
+
) : (
-
-
-
+
)}
- {/* Dashboard */}
-
-
-
-
- {!collapsed && Dashboard }
-
-
- {/* Wiki Pages */}
-
-
-
-
- {!collapsed && Wiki Pages }
-
-
- {/* Users */}
-
-
-
-
- {!collapsed && Users }
-
-
- {/* Groups */}
-
-
-
-
- {!collapsed && Groups }
-
-
- {/* Assets */}
-
-
-
-
- {!collapsed && Assets }
-
-
- {/* Permission Example */}
- {env.NEXT_PUBLIC_NODE_ENV === "development" && (
-
-
-
-
-
- {!collapsed && Permission Example }
-
+ {navigationItems.map(
+ (item) =>
+ (item.condition === undefined || item.condition) && (
+
+
+ {!collapsed && {item.label} }
+
+ )
)}
-
- {/* Settings */}
-
-
-
-
-
- {!collapsed && Settings }
-
-
-
-
+
{!collapsed &&
Back to Wiki }
@@ -301,9 +133,10 @@ export function AdminLayout({ children }: AdminLayoutProps) {
{/* Main content */}
{/* Top bar */}
@@ -318,7 +151,7 @@ export function AdminLayout({ children }: AdminLayoutProps) {
{/* Content */}
- {children}
+ {children}
);
diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx
index 91c41cb..b746aeb 100644
--- a/apps/web/src/components/layout/Header.tsx
+++ b/apps/web/src/components/layout/Header.tsx
@@ -9,10 +9,18 @@ import Link from "next/link";
import { WikiLockInfo } from "~/components/wiki/WikiLockInfo";
import { MoveIcon, PencilIcon } from "lucide-react";
import { ClientRequirePermission } from "~/components/auth/permission/client";
+import { getSettingValue } from "~/lib/utils/settings";
-export function Header({ pageMetadata }: { pageMetadata?: PageMetadata }) {
+export async function Header({
+ pageMetadata,
+}: {
+ pageMetadata?: PageMetadata;
+}) {
const isHomePage = pageMetadata?.path === "index";
+ // Get site title from settings
+ const siteTitle = await getSettingValue("site.title");
+
return (
- {pageMetadata.title}
+ {siteTitle}
)}
diff --git a/apps/web/src/components/layout/page-header.tsx b/apps/web/src/components/layout/page-header.tsx
new file mode 100644
index 0000000..dfa55b5
--- /dev/null
+++ b/apps/web/src/components/layout/page-header.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+
+interface PageHeaderProps {
+ title: string;
+ description?: string;
+ action?: React.ReactNode;
+}
+
+export function PageHeader({ title, description, action }: PageHeaderProps) {
+ return (
+
+
+
+ {title}
+
+ {description && (
+
{description}
+ )}
+
+ {action &&
{action}
}
+
+ );
+}
diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts
index 5ad3749..6dafc28 100644
--- a/apps/web/src/lib/auth.ts
+++ b/apps/web/src/lib/auth.ts
@@ -10,6 +10,8 @@ import { compare } from "bcryptjs";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { env } from "~/env";
+// FIXME: Migrate to v5 and use session tokens
+
// Helper function to handle provider import differences between environments
// Ignore any type errors here, we know the providers are valid
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/apps/web/src/lib/hooks/use-settings.ts b/apps/web/src/lib/hooks/use-settings.ts
new file mode 100644
index 0000000..46c6500
--- /dev/null
+++ b/apps/web/src/lib/hooks/use-settings.ts
@@ -0,0 +1,101 @@
+import { useTRPC } from "~/server/client";
+import type { SettingCategory, SettingKey, SettingValue } from "@repo/types";
+import { useQuery } from "@tanstack/react-query";
+
+/**
+ * Hook for accessing a single application setting
+ * @param key The setting key to retrieve
+ * @param options.fallback Optional fallback value if setting can't be loaded
+ * @returns The setting value, loading state, and error state
+ */
+export function useSetting(
+ key: K,
+ options: {
+ fallback?: SettingValue;
+ } = {}
+) {
+ const trpc = useTRPC();
+ const { data, isLoading, error } = useQuery(
+ trpc.admin.settings.get.queryOptions(
+ { key },
+ {
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ refetchOnWindowFocus: false,
+ }
+ )
+ );
+
+ // Get either the loaded value, fallback, or null
+ const value = data
+ ? data.value
+ : options.fallback !== undefined
+ ? options.fallback
+ : null;
+
+ return {
+ value: value as SettingValue | null,
+ isLoading,
+ error,
+ };
+}
+
+/**
+ * Hook for accessing multiple application settings
+ * @param keys Array of setting keys to retrieve
+ * @returns Object with all settings, loading state, and a method to refetch
+ */
+export function useSettings(keys: K[]) {
+ const trpc = useTRPC();
+ const {
+ data: allSettings,
+ isLoading,
+ refetch,
+ } = useQuery(
+ trpc.admin.settings.getAll.queryOptions(undefined, {
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ refetchOnWindowFocus: false,
+ })
+ );
+
+ // Extract requested settings
+ const settings =
+ !isLoading && allSettings
+ ? keys.reduce(
+ (acc, key) => {
+ acc[key] = allSettings[key] as SettingValue;
+ return acc;
+ },
+ {} as Record>
+ )
+ : null;
+
+ return {
+ settings,
+ isLoading,
+ refetch,
+ };
+}
+
+/**
+ * Hook for getting settings within a specific category
+ * @param category The settings category to retrieve
+ * @returns Array of settings in the category, loading state, and error state
+ */
+export function useSettingsByCategory(category: string) {
+ const trpc = useTRPC();
+ const { data, isLoading, error } = useQuery(
+ trpc.admin.settings.getByCategory.queryOptions(
+ { category: category as SettingCategory },
+ {
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ refetchOnWindowFocus: false,
+ }
+ )
+ );
+
+ return {
+ settings: data || [],
+ isLoading,
+ error,
+ };
+}
diff --git a/apps/web/src/lib/hooks/useDebounce.ts b/apps/web/src/lib/hooks/useDebounce.ts
new file mode 100644
index 0000000..6296377
--- /dev/null
+++ b/apps/web/src/lib/hooks/useDebounce.ts
@@ -0,0 +1,31 @@
+import { useState, useEffect } from "react";
+
+/**
+ * Custom hook to debounce a value.
+ * @param value The value to debounce.
+ * @param delay The debounce delay in milliseconds.
+ * @returns The debounced value.
+ */
+export function useDebounce(value: T, delay: number): T {
+ // State and setters for debounced value
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(
+ () => {
+ // Update debounced value after delay
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ // Cancel the timeout if value changes (also on delay change or unmount)
+ // This is how we prevent debounced value from updating if value is changed ...
+ // .. within the delay period. Timeout gets cleared and restarted.
+ return () => {
+ clearTimeout(handler);
+ };
+ },
+ [value, delay] // Only re-call effect if value or delay changes
+ );
+
+ return debouncedValue;
+}
diff --git a/apps/web/src/lib/permissions/validation.ts b/apps/web/src/lib/permissions/validation.ts
index b887278..eb60257 100644
--- a/apps/web/src/lib/permissions/validation.ts
+++ b/apps/web/src/lib/permissions/validation.ts
@@ -4,9 +4,15 @@
* Only import from server components or API routes
*/
import { db } from "@repo/db";
-import { permissions } from "@repo/db";
+import {
+ permissions as permissionsTable,
+ type Permission as DbPermissionType, // Type for DB permission rows
+} from "@repo/db";
import { eq } from "drizzle-orm";
-import { getAllPermissions, createPermissionId } from "@repo/db";
+import {
+ getAllPermissions as getRegistryPermissions, // Rename for clarity
+ type Permission as RegistryPermissionType, // Type for registry permission objects
+} from "@repo/db";
import { logger } from "@repo/logger";
/**
@@ -14,40 +20,95 @@ import { logger } from "@repo/logger";
* Returns an object containing the validation results
*/
export async function validatePermissionsDatabase() {
- // Get all permissions from the database
+ // Get registry and DB data
+ const registryPermissions = getRegistryPermissions();
const dbPermissions = await db.query.permissions.findMany();
- const registryPermissions = getAllPermissions();
+ const dbModules = await db.query.modules.findMany();
+ const dbActions = await db.query.actions.findMany();
+
+ // Create lookups for faster comparison
+ const moduleNameToId = new Map(dbModules.map((m) => [m.name, m.id]));
+ const actionNameToId = new Map(dbActions.map((a) => [a.name, a.id]));
+ const moduleIdToName = new Map(dbModules.map((m) => [m.id, m.name]));
+ const actionIdToName = new Map(dbActions.map((a) => [a.id, a.name]));
// Find permissions that are in the registry but missing from the database
- const missing = registryPermissions.filter((expected) => {
- const name = createPermissionId(expected);
- return !dbPermissions.some((p) => p.name === name);
+ const missing = registryPermissions.filter((regPerm) => {
+ const expectedModuleId = moduleNameToId.get(regPerm.module);
+ const expectedActionId = actionNameToId.get(regPerm.action);
+
+ // Skip if module/action name from registry doesn't exist in DB (separate issue?)
+ if (expectedModuleId === undefined || expectedActionId === undefined) {
+ logger.error(
+ `Registry permission '${regPerm.module}:${regPerm.resource}:${regPerm.action}' refers to unknown module or action.`
+ );
+ return false; // Treat as invalid registry entry for this validation
+ }
+
+ return !dbPermissions.some(
+ (dbPerm) =>
+ dbPerm.moduleId === expectedModuleId &&
+ dbPerm.resource === regPerm.resource &&
+ dbPerm.actionId === expectedActionId
+ );
});
// Find permissions that are in the database but not in the registry
const extras = dbPermissions.filter((dbPerm) => {
- // Check if the permission name exists in the registry
+ const moduleName = moduleIdToName.get(dbPerm.moduleId);
+ const actionName = actionIdToName.get(dbPerm.actionId);
+
+ // Skip if module/action ID from DB doesn't exist in lookup (data integrity issue?)
+ if (moduleName === undefined || actionName === undefined) {
+ logger.error(
+ `DB permission ID ${dbPerm.id} refers to unknown module ID ${dbPerm.moduleId} or action ID ${dbPerm.actionId}.`
+ );
+ return false; // Treat as invalid DB entry for this validation
+ }
+
return !registryPermissions.some(
- (regPerm) => createPermissionId(regPerm) === dbPerm.name
+ (regPerm) =>
+ regPerm.module === moduleName &&
+ regPerm.resource === dbPerm.resource &&
+ regPerm.action === actionName
);
});
// Find permissions that have different descriptions
const mismatched = dbPermissions.filter((dbPerm) => {
+ const moduleName = moduleIdToName.get(dbPerm.moduleId);
+ const actionName = actionIdToName.get(dbPerm.actionId);
+
+ if (moduleName === undefined || actionName === undefined) {
+ // Already logged as error above if it's an extra, avoid double logging
+ return false;
+ }
+
const regPerm = registryPermissions.find(
- (p) => createPermissionId(p) === dbPerm.name
+ (p) =>
+ p.module === moduleName &&
+ p.resource === dbPerm.resource &&
+ p.action === actionName
+ );
+ // Ensure description is compared correctly (handle null/undefined)
+ return (
+ regPerm && (regPerm.description ?? null) !== (dbPerm.description ?? null)
);
- return regPerm && regPerm.description !== dbPerm.description;
});
return {
isValid:
missing.length === 0 && extras.length === 0 && mismatched.length === 0,
- missing,
- extras,
- mismatched,
- dbPermissions,
- registryPermissions,
+ missing, // Registry items not in DB
+ extras, // DB items not in Registry
+ mismatched, // DB items with different description than Registry
+ dbPermissions, // Raw DB data
+ registryPermissions, // Raw Registry data
+ // Include maps for potential use in logging/fixing if needed
+ moduleIdToName,
+ actionIdToName,
+ moduleNameToId,
+ actionNameToId,
};
}
@@ -56,7 +117,9 @@ export async function validatePermissionsDatabase() {
* Adds missing permissions, updates mismatched descriptions, and optionally removes extras
*/
export async function fixPermissionsDatabase(removeExtras = false) {
+ // Pass lookups to avoid re-fetching
const validation = await validatePermissionsDatabase();
+
const results = {
added: 0,
updated: 0,
@@ -64,35 +127,56 @@ export async function fixPermissionsDatabase(removeExtras = false) {
};
// Add missing permissions
- for (const permission of validation.missing) {
- await db.insert(permissions).values({
- module: permission.module,
- resource: permission.resource,
- action: permission.action,
- description: permission.description,
- });
- results.added++;
+ for (const regPerm of validation.missing) {
+ const moduleId = validation.moduleNameToId.get(regPerm.module);
+ const actionId = validation.actionNameToId.get(regPerm.action);
+
+ // Ensure we have valid IDs before inserting
+ if (moduleId !== undefined && actionId !== undefined) {
+ await db.insert(permissionsTable).values({
+ moduleId: moduleId,
+ resource: regPerm.resource,
+ actionId: actionId,
+ description: regPerm.description,
+ });
+ results.added++;
+ } else {
+ logger.error(
+ `Could not add missing permission '${regPerm.module}:${regPerm.resource}:${regPerm.action}' because module or action name not found in DB.`
+ );
+ }
}
// Update mismatched descriptions
for (const dbPerm of validation.mismatched) {
- const regPerm = validation.registryPermissions.find(
- (p) => createPermissionId(p) === dbPerm.name
- );
+ const moduleName = validation.moduleIdToName.get(dbPerm.moduleId);
+ const actionName = validation.actionIdToName.get(dbPerm.actionId);
- if (regPerm) {
- await db
- .update(permissions)
- .set({ description: regPerm.description })
- .where(eq(permissions.id, dbPerm.id));
- results.updated++;
+ // Should always be findable based on mismatch logic, but check anyway
+ if (moduleName && actionName) {
+ const regPerm = validation.registryPermissions.find(
+ (p) =>
+ p.module === moduleName &&
+ p.resource === dbPerm.resource &&
+ p.action === actionName
+ );
+
+ if (regPerm) {
+ await db
+ .update(permissionsTable)
+ .set({ description: regPerm.description ?? null })
+ .where(eq(permissionsTable.id, dbPerm.id));
+ results.updated++;
+ }
}
}
// Remove extras if requested
if (removeExtras) {
for (const extra of validation.extras) {
- await db.delete(permissions).where(eq(permissions.id, extra.id));
+ await db
+ .delete(permissionsTable)
+ .where(eq(permissionsTable.id, extra.id));
results.removed++;
}
}
@@ -104,6 +188,7 @@ export async function fixPermissionsDatabase(removeExtras = false) {
* Logs the result of a permissions database validation
*/
export function logValidationResults(
+ // Infer the type correctly from the return value
validation: Awaited>
) {
if (validation.isValid) {
@@ -111,10 +196,33 @@ export function logValidationResults(
return;
}
+ // Helper to format permission identifier from Registry type
+ const formatRegPermId = (p: RegistryPermissionType) =>
+ `${p.module}:${p.resource}:${p.action}`;
+
+ // Helper to format permission identifier from DB type using lookups
+ const formatDbPermId = (p: {
+ moduleId: number;
+ actionId: number;
+ resource: string;
+ }) => {
+ // Use the maps passed within the validation object
+ const moduleName =
+ validation.moduleIdToName.get(p.moduleId) ?? `ModID ${p.moduleId}`;
+ const actionName =
+ validation.actionIdToName.get(p.actionId) ?? `ActID ${p.actionId}`;
+ return `${moduleName}:${p.resource}:${actionName}`;
+ };
+
if (validation.missing.length > 0) {
- logger.warn(`⚠️ Found ${validation.missing.length} missing permissions:`);
- validation.missing.forEach((p) => {
- logger.warn(` - ${createPermissionId(p)}: ${p.description}`);
+ logger.warn(
+ `⚠️ Found ${validation.missing.length} missing permissions (Registry -> DB):`
+ );
+ // Explicitly type 'p' as RegistryPermissionType - This is correct as validation.missing contains Registry types
+ validation.missing.forEach((p: RegistryPermissionType) => {
+ logger.warn(
+ ` - ${formatRegPermId(p)}: ${p.description ?? "(no description)"}`
+ );
});
}
@@ -122,24 +230,40 @@ export function logValidationResults(
logger.warn(
`⚠️ Found ${validation.mismatched.length} permissions with mismatched descriptions:`
);
+ // Remove explicit type ': DbPermissionType'. Let TS infer from validation.mismatched array.
validation.mismatched.forEach((dbPerm) => {
- const regPerm = validation.registryPermissions.find(
- (p) => createPermissionId(p) === dbPerm.name
+ // Find corresponding registry permission (logic seems okay)
+ const regPerm = validation.registryPermissions.find((p) => {
+ const modId = validation.moduleNameToId.get(p.module);
+ const actId = validation.actionNameToId.get(p.action);
+ // Now dbPerm correctly has moduleId and actionId from inference
+ return (
+ modId === dbPerm.moduleId &&
+ p.resource === dbPerm.resource &&
+ actId === dbPerm.actionId
+ );
+ });
+ // Pass the inferred dbPerm to the formatter.
+ // Need to adjust formatDbPermId signature or cast dbPerm if necessary.
+ // Let's adjust formatDbPermId to accept the inferred type.
+ logger.warn(` - ${formatDbPermId(dbPerm)}:`);
+ logger.warn(` DB: ${dbPerm.description ?? "(null)"}`);
+ logger.warn(
+ ` Registry: ${regPerm?.description ?? "(Not found in registry?)"}`
);
- if (regPerm) {
- logger.warn(` - ${dbPerm.name}:`);
- logger.warn(` DB: ${dbPerm.description}`);
- logger.warn(` Registry: ${regPerm.description}`);
- }
});
}
if (validation.extras.length > 0) {
logger.warn(
- `⚠️ Found ${validation.extras.length} extra permissions in the database:`
+ `⚠️ Found ${validation.extras.length} extra permissions in the database (DB only):`
);
+ // Remove explicit type ': DbPermissionType'. Let TS infer from validation.extras array.
validation.extras.forEach((p) => {
- logger.warn(` - ${p.name}: ${p.description}`);
+ // Pass the inferred p to the formatter
+ logger.warn(
+ ` - ${formatDbPermId(p)}: ${p.description ?? "(no description)"}`
+ );
});
}
}
diff --git a/apps/web/src/lib/services/actions.ts b/apps/web/src/lib/services/actions.ts
new file mode 100644
index 0000000..dfc6b49
--- /dev/null
+++ b/apps/web/src/lib/services/actions.ts
@@ -0,0 +1,39 @@
+import { db } from "@repo/db";
+import { actions } from "@repo/db";
+import { eq } from "drizzle-orm";
+
+/**
+ * Action Service
+ *
+ * Handles operations related to permission actions
+ */
+export const actionService = {
+ /**
+ * Get all actions in the system
+ */
+ async getAll() {
+ return db.query.actions.findMany({
+ orderBy: (actions, { asc }) => [asc(actions.name)],
+ });
+ },
+
+ /**
+ * Get an action by ID
+ */
+ async getById(id: number) {
+ return db.query.actions.findFirst({
+ where: eq(actions.id, id),
+ });
+ },
+
+ /**
+ * Get an action by Name
+ */
+ async getByName(name: string) {
+ return db.query.actions.findFirst({
+ where: eq(actions.name, name),
+ });
+ },
+
+ // Add create, update, delete methods if needed later
+};
diff --git a/apps/web/src/lib/services/authorization.ts b/apps/web/src/lib/services/authorization.ts
index 7685f43..c14f279 100644
--- a/apps/web/src/lib/services/authorization.ts
+++ b/apps/web/src/lib/services/authorization.ts
@@ -5,6 +5,8 @@ import {
permissions,
pagePermissions,
groups,
+ modules,
+ actions,
} from "@repo/db";
import { eq, and, inArray, or, isNull } from "drizzle-orm";
import { PermissionIdentifier, validatePermissionId } from "@repo/db";
@@ -128,9 +130,13 @@ export const authorizationService = {
return [];
}
- // Get permission details
+ // Get permission details, including related module and action names
return db.query.permissions.findMany({
where: inArray(permissions.id, permissionIds),
+ with: {
+ module: { columns: { name: true } }, // Include module name
+ action: { columns: { name: true } }, // Include action name
+ },
});
},
@@ -153,24 +159,48 @@ export const authorizationService = {
return false;
}
- // 1. Parse permission name and find the corresponding permission ID
- const [module, resource, action] = permissionName.split(":");
- if (!module || !resource || !action) {
+ // 1. Parse permission name and find the corresponding Module, Action, and Permission IDs
+ const [moduleName, resource, actionName] = permissionName.split(":");
+ if (!moduleName || !resource || !actionName) {
logger.error(`Invalid permission format: ${permissionName}`);
return false; // Invalid format
}
+ // Fetch Module and Action IDs first
+ const [moduleRecord, actionRecord] = await Promise.all([
+ db.query.modules.findFirst({
+ where: eq(modules.name, moduleName),
+ columns: { id: true },
+ }),
+ db.query.actions.findFirst({
+ where: eq(actions.name, actionName),
+ columns: { id: true },
+ }),
+ ]);
+
+ if (!moduleRecord || !actionRecord) {
+ logger.warn(
+ `Module ('${moduleName}') or Action ('${actionName}') not found for permission: ${permissionName}`
+ );
+ return false; // Module or Action doesn't exist
+ }
+ const moduleId = moduleRecord.id;
+ const actionId = actionRecord.id;
+
+ // Now find the permission using IDs and resource
const permission = await db.query.permissions.findFirst({
where: and(
- eq(permissions.module, module),
+ eq(permissions.moduleId, moduleId),
eq(permissions.resource, resource),
- eq(permissions.action, action)
+ eq(permissions.actionId, actionId)
),
columns: { id: true }, // Only need the ID
});
if (!permission) {
- // logger.warn(`Permission not found: ${permissionName}`);
+ logger.warn(
+ `Permission not found in DB for components: module=${moduleId}, resource=${resource}, action=${actionId}`
+ );
return false; // Permission doesn't exist in the system
}
const permissionId = permission.id;
@@ -190,10 +220,10 @@ export const authorizationService = {
columns: { permissionId: true },
},
groupModulePermissions: {
- columns: { module: true },
+ columns: { moduleId: true },
},
groupActionPermissions: {
- columns: { action: true },
+ columns: { actionId: true },
},
},
});
@@ -213,7 +243,7 @@ export const authorizationService = {
// Check module permissions
const hasModulePermission = guestGroup.groupModulePermissions.some(
- (mp) => mp.module === module
+ (mp) => mp.moduleId === moduleId
);
if (
!hasModulePermission &&
@@ -224,7 +254,7 @@ export const authorizationService = {
// Check action permissions
const hasActionPermission = guestGroup.groupActionPermissions.some(
- (ap) => ap.action === action
+ (ap) => ap.actionId === actionId
);
if (
!hasActionPermission &&
@@ -237,7 +267,7 @@ export const authorizationService = {
return true;
}
- // 2. Get all groups the user belongs to, including their permissions and permissions
+ // 2. Get all groups the user belongs to, including their relevant permissions
const userGroupsData = await db.query.userGroups.findMany({
where: eq(userGroups.userId, userId),
with: {
@@ -246,18 +276,12 @@ export const authorizationService = {
// Eagerly load necessary related data for checking
groupPermissions: {
columns: { permissionId: true }, // Only need permissionId
- // Optimization: Could filter here if DB supports it well
- // where: eq(groupPermissions.permissionId, permissionId)
},
groupModulePermissions: {
- columns: { module: true }, // Only need module name
- // Optimization: Could filter here
- // where: eq(groupModulePermissions.module, module)
+ columns: { moduleId: true },
},
groupActionPermissions: {
- columns: { action: true }, // Only need action name
- // Optimization: Could filter here
- // where: eq(groupActionPermissions.action, action)
+ columns: { actionId: true },
},
},
},
@@ -286,7 +310,7 @@ export const authorizationService = {
// Is this group restricted for the required module?
// If the group has no module permissions, it is unrestricted
const hasModulePermission = group.groupModulePermissions.some(
- (mp) => mp.module === module
+ (mp) => mp.moduleId === moduleId
);
if (!hasModulePermission && group.groupModulePermissions.length > 0) {
continue; // Module restricted for this group, try next group
@@ -295,7 +319,7 @@ export const authorizationService = {
// Is this group restricted for the required action?
// If the group has no action permissions, it is unrestricted
const hasActionPermission = group.groupActionPermissions.some(
- (ap) => ap.action === action
+ (ap) => ap.actionId === actionId
);
if (!hasActionPermission && group.groupActionPermissions.length > 0) {
continue; // Action restricted for this group, try next group
@@ -347,30 +371,54 @@ export const authorizationService = {
pageId: number,
permissionName: PermissionIdentifier
) {
- // Validate permission format
+ // Input validation remains the same
if (!validatePermissionId(permissionName)) {
logger.error(`Invalid permission format: ${permissionName}`);
return false;
}
// Parse the permission name to get module, resource, and action
- const [module, resource, action] = permissionName.split(":");
+ const [moduleName, resource, actionName] = permissionName.split(":");
- if (!module || !resource || !action) {
+ if (!moduleName || !resource || !actionName) {
return false;
}
- // Get the permission ID using the specific components
+ // Fetch Module and Action IDs first
+ const [moduleRecord, actionRecord] = await Promise.all([
+ db.query.modules.findFirst({
+ where: eq(modules.name, moduleName),
+ columns: { id: true },
+ }),
+ db.query.actions.findFirst({
+ where: eq(actions.name, actionName),
+ columns: { id: true },
+ }),
+ ]);
+
+ if (!moduleRecord || !actionRecord) {
+ logger.warn(
+ `Module ('${moduleName}') or Action ('${actionName}') not found for permission: ${permissionName}`
+ );
+ return false; // Module or Action doesn't exist
+ }
+ const moduleId = moduleRecord.id;
+ const actionId = actionRecord.id;
+
+ // Find the permission ID using fetched IDs and resource
const permission = await db.query.permissions.findFirst({
where: and(
- eq(permissions.module, module),
+ eq(permissions.moduleId, moduleId),
eq(permissions.resource, resource),
- eq(permissions.action, action)
+ eq(permissions.actionId, actionId)
),
columns: { id: true }, // Only need the ID
});
if (!permission) {
+ logger.warn(
+ `Permission not found in DB for components: module=${moduleId}, resource=${resource}, action=${actionId}`
+ );
return false;
}
const permissionId = permission.id;
diff --git a/apps/web/src/lib/services/groups.ts b/apps/web/src/lib/services/groups.ts
index b847b2f..1268b11 100644
--- a/apps/web/src/lib/services/groups.ts
+++ b/apps/web/src/lib/services/groups.ts
@@ -9,6 +9,7 @@ import {
import { eq, and, inArray } from "drizzle-orm";
import type { groups as groupsTable } from "@repo/db";
import { logger } from "../utils/logger";
+import { TRPCError } from "@trpc/server";
type Group = typeof groupsTable.$inferSelect;
@@ -125,7 +126,18 @@ export const groupService = {
// Ensure the group exists
const group = await this.getById(groupId);
if (!group) {
- throw new Error(`Group with id ${groupId} not found`);
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: `Group with id ${groupId} not found`,
+ });
+ }
+
+ // Check if users can be assigned to this group
+ if (!group.allowUserAssignment) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Users cannot be assigned to the group "${group.name}".`,
+ });
}
// Get existing user-group associations
@@ -311,8 +323,10 @@ export const groupService = {
/**
* Add module permissions to a group
+ * @param groupId The group ID
+ * @param moduleIds An array of module IDs to assign
*/
- async addModulePermissions(groupId: number, modules: string[]) {
+ async addModulePermissions(groupId: number, moduleIds: number[]) {
// Ensure the group exists
const group = await this.getById(groupId);
if (!group) {
@@ -321,19 +335,19 @@ export const groupService = {
// Get existing module permissions
const existingModulePermissions = await this.getModulePermissions(groupId);
- const existingModules = existingModulePermissions.map((p) => p.module);
+ const existingModuleIds = existingModulePermissions.map((p) => p.moduleId);
// Remove modules that are not in the new list
- const modulesToRemove = existingModules.filter(
- (module) => !modules.includes(module)
+ const modulesToRemove = existingModuleIds.filter(
+ (id) => !moduleIds.includes(id)
);
if (modulesToRemove.length > 0) {
await this.removeModulePermissions(groupId, modulesToRemove);
}
// Find modules that need to be added (not already existing)
- const modulesToAdd = modules.filter(
- (module) => !existingModules.includes(module)
+ const modulesToAdd = moduleIds.filter(
+ (id) => !existingModuleIds.includes(id)
);
if (modulesToAdd.length === 0) {
@@ -342,9 +356,9 @@ export const groupService = {
// Add new module permissions
await db.insert(groupModulePermissions).values(
- modulesToAdd.map((module) => ({
+ modulesToAdd.map((moduleId) => ({
groupId,
- module,
+ moduleId,
}))
);
@@ -353,8 +367,10 @@ export const groupService = {
/**
* Add action permissions to a group
+ * @param groupId The group ID
+ * @param actionIds An array of action IDs to assign
*/
- async addActionPermissions(groupId: number, actions: string[]) {
+ async addActionPermissions(groupId: number, actionIds: number[]) {
// Ensure the group exists
const group = await this.getById(groupId);
if (!group) {
@@ -363,19 +379,19 @@ export const groupService = {
// Get existing action permissions
const existingActionPermissions = await this.getActionPermissions(groupId);
- const existingActions = existingActionPermissions.map((p) => p.action);
+ const existingActionIds = existingActionPermissions.map((p) => p.actionId);
// Remove actions that are not in the new list
- const actionsToRemove = existingActions.filter(
- (action) => !actions.includes(action)
+ const actionsToRemove = existingActionIds.filter(
+ (id) => !actionIds.includes(id)
);
if (actionsToRemove.length > 0) {
await this.removeActionPermissions(groupId, actionsToRemove);
}
// Find actions that need to be added (not already existing)
- const actionsToAdd = actions.filter(
- (action) => !existingActions.includes(action)
+ const actionsToAdd = actionIds.filter(
+ (id) => !existingActionIds.includes(id)
);
if (actionsToAdd.length === 0) {
@@ -384,9 +400,9 @@ export const groupService = {
// Add new action permissions
await db.insert(groupActionPermissions).values(
- actionsToAdd.map((action) => ({
+ actionsToAdd.map((actionId) => ({
groupId,
- action,
+ actionId,
}))
);
@@ -394,7 +410,7 @@ export const groupService = {
},
/**
- * Get module permissions for a group
+ * Get module permissions for a group (returns the full relation objects)
*/
async getModulePermissions(groupId: number) {
return db.query.groupModulePermissions.findMany({
@@ -403,7 +419,7 @@ export const groupService = {
},
/**
- * Get action permissions for a group
+ * Get action permissions for a group (returns the full relation objects)
*/
async getActionPermissions(groupId: number) {
return db.query.groupActionPermissions.findMany({
@@ -413,33 +429,37 @@ export const groupService = {
/**
* Remove module permissions from a group
+ * @param groupId The group ID
+ * @param moduleIds An array of module IDs to remove
*/
- async removeModulePermissions(groupId: number, modules: string[]) {
+ async removeModulePermissions(groupId: number, moduleIds: number[]) {
await db
.delete(groupModulePermissions)
.where(
and(
eq(groupModulePermissions.groupId, groupId),
- inArray(groupModulePermissions.module, modules)
+ inArray(groupModulePermissions.moduleId, moduleIds)
)
);
- return { removed: modules.length };
+ return { removed: moduleIds.length };
},
/**
* Remove action permissions from a group
+ * @param groupId The group ID
+ * @param actionIds An array of action IDs to remove
*/
- async removeActionPermissions(groupId: number, actions: string[]) {
+ async removeActionPermissions(groupId: number, actionIds: number[]) {
await db
.delete(groupActionPermissions)
.where(
and(
eq(groupActionPermissions.groupId, groupId),
- inArray(groupActionPermissions.action, actions)
+ inArray(groupActionPermissions.actionId, actionIds)
)
);
- return { removed: actions.length };
+ return { removed: actionIds.length };
},
};
diff --git a/apps/web/src/lib/services/index.ts b/apps/web/src/lib/services/index.ts
index 38af301..6c900a1 100644
--- a/apps/web/src/lib/services/index.ts
+++ b/apps/web/src/lib/services/index.ts
@@ -9,6 +9,8 @@ import { groupService } from "./groups";
import { authorizationService } from "./authorization";
import { markdownService } from "./markdown";
import { systemService } from "./system";
+import { moduleService } from "./modules";
+import { actionService } from "./actions";
/**
* Database Services
@@ -20,7 +22,7 @@ import { systemService } from "./system";
* ```
* import { dbService } from '~/lib/services';
*
- * // In a server component:
+ * // In a server component
* const userCount = await dbService.users.count();
* const recentPages = await dbService.wiki.getRecentPages(5);
* ```
@@ -80,6 +82,16 @@ export const dbService = {
* System operations
*/
system: systemService,
+
+ /**
+ * Module management operations
+ */
+ modules: moduleService,
+
+ /**
+ * Action management operations
+ */
+ actions: actionService,
};
// Export individual services for direct use
@@ -94,4 +106,6 @@ export {
groupService,
authorizationService,
markdownService,
+ moduleService,
+ actionService,
};
diff --git a/apps/web/src/lib/services/modules.ts b/apps/web/src/lib/services/modules.ts
new file mode 100644
index 0000000..0d09525
--- /dev/null
+++ b/apps/web/src/lib/services/modules.ts
@@ -0,0 +1,39 @@
+import { db } from "@repo/db";
+import { modules } from "@repo/db";
+import { eq } from "drizzle-orm";
+
+/**
+ * Module Service
+ *
+ * Handles operations related to permission modules
+ */
+export const moduleService = {
+ /**
+ * Get all modules in the system
+ */
+ async getAll() {
+ return db.query.modules.findMany({
+ orderBy: (modules, { asc }) => [asc(modules.name)],
+ });
+ },
+
+ /**
+ * Get a module by ID
+ */
+ async getById(id: number) {
+ return db.query.modules.findFirst({
+ where: eq(modules.id, id),
+ });
+ },
+
+ /**
+ * Get a module by Name
+ */
+ async getByName(name: string) {
+ return db.query.modules.findFirst({
+ where: eq(modules.name, name),
+ });
+ },
+
+ // Add create, update, delete methods if needed later
+};
diff --git a/apps/web/src/lib/services/permissions.ts b/apps/web/src/lib/services/permissions.ts
index c54b117..45c1cfe 100644
--- a/apps/web/src/lib/services/permissions.ts
+++ b/apps/web/src/lib/services/permissions.ts
@@ -9,14 +9,27 @@ import { eq } from "drizzle-orm";
*/
export const permissionService = {
/**
- * Get all permissions in the system
+ * Get all permissions in the system including related module and action names
*/
async getAll() {
return db.query.permissions.findMany({
orderBy: (permissions, { asc }) => [
- asc(permissions.module),
- asc(permissions.action),
+ asc(permissions.moduleId),
+ asc(permissions.resource),
+ asc(permissions.actionId),
],
+ with: {
+ module: {
+ columns: {
+ name: true,
+ },
+ },
+ action: {
+ columns: {
+ name: true,
+ },
+ },
+ },
});
},
@@ -31,25 +44,26 @@ export const permissionService = {
/**
* Create a new permission
+ * Note: Expects IDs for module and action
*/
async create({
description,
- module,
+ moduleId,
resource,
- action,
+ actionId,
}: {
description?: string;
- module: string;
+ moduleId: number;
resource: string;
- action: string;
+ actionId: number;
}) {
const result = await db
.insert(permissions)
.values({
description,
- module,
+ moduleId,
resource,
- action,
+ actionId,
})
.returning();
@@ -58,28 +72,29 @@ export const permissionService = {
/**
* Update an existing permission
+ * Note: Expects IDs for module and action if provided
*/
async update(
id: number,
{
description,
- module,
+ moduleId,
resource,
- action,
+ actionId,
}: {
description?: string;
- module?: string;
+ moduleId?: number;
resource?: string;
- action?: string;
+ actionId?: number;
}
) {
const result = await db
.update(permissions)
.set({
description,
- module,
+ moduleId,
resource,
- action,
+ actionId,
})
.where(eq(permissions.id, id))
.returning();
diff --git a/apps/web/src/lib/services/settings.ts b/apps/web/src/lib/services/settings.ts
new file mode 100644
index 0000000..01fd135
--- /dev/null
+++ b/apps/web/src/lib/services/settings.ts
@@ -0,0 +1,236 @@
+/**
+ * Settings service for managing application settings
+ */
+import { db } from "@repo/db";
+import { settings, settingsHistory } from "@repo/db";
+import type {
+ SettingKey,
+ SettingValue,
+ SettingHistoryEntry,
+} from "@repo/types";
+import { eq, desc } from "drizzle-orm";
+import { cache } from "react";
+
+/**
+ * Get a single setting by key
+ * @param key The setting key
+ * @returns The setting value or the default value if not found
+ */
+export async function getSetting(
+ key: K
+): Promise> {
+ const result = await db.query.settings.findFirst({
+ where: eq(settings.key, key),
+ columns: { value: true },
+ });
+
+ // If the setting doesn't exist in the database, return the default value
+ if (!result) {
+ // Note: We're doing a dynamic import here to avoid circular dependencies
+ const { getDefaultSetting } = await import("@repo/types");
+ return getDefaultSetting(key);
+ }
+
+ return result.value as SettingValue;
+}
+
+/**
+ * Get multiple settings by their keys
+ * @param keys The setting keys
+ * @returns Object with requested settings
+ */
+export async function getSettings(
+ keys: K[]
+): Promise>> {
+ const results = await Promise.all(keys.map((key) => getSetting(key)));
+
+ return keys.reduce(
+ (acc, key, index) => {
+ acc[key] = results[index] as SettingValue;
+ return acc;
+ },
+ {} as Record>
+ );
+}
+
+/**
+ * Get all settings from the database
+ * @returns All settings
+ */
+export async function getAllSettings(): Promise<
+ Partial<{ [K in SettingKey]: SettingValue }>
+> {
+ const results = await db.query.settings.findMany();
+ const settingsMap: Partial<{ [K in SettingKey]: SettingValue }> = {};
+
+ for (const setting of results) {
+ const key = setting.key as SettingKey;
+ // Type assertion still needed for the value from DB
+ // @ts-expect-error - TODO: fix this
+ settingsMap[key] = setting.value as SettingValue;
+ }
+
+ return settingsMap;
+}
+
+/**
+ * Update a setting value with history tracking
+ * @param key The setting key
+ * @param value The new value
+ * @param userId The ID of the user making the change
+ * @param reason Optional reason for the change
+ */
+export async function updateSetting(
+ key: K,
+ value: SettingValue,
+ userId: number,
+ reason?: string
+): Promise {
+ await db.transaction(async (tx) => {
+ // 1. Get current value to store as previous
+ const currentSetting = await tx.query.settings.findFirst({
+ where: eq(settings.key, key),
+ columns: { value: true },
+ });
+ const previousValue = currentSetting?.value ?? null;
+
+ // 2. Insert into history if the value exists and is changing
+ if (previousValue !== null) {
+ await tx.insert(settingsHistory).values({
+ settingKey: key,
+ previousValue: previousValue,
+ changedById: userId,
+ changeReason: reason ?? null,
+ // changedAt will default to now()
+ });
+ }
+
+ // 3. Update or insert the setting
+ await tx
+ .insert(settings)
+ .values({
+ key: key,
+ value: value as any, // Type cast needed due to DB Jsonb vs specific types
+ description: (await import("@repo/types")).DEFAULT_SETTINGS[key]
+ .description,
+ updatedAt: new Date(),
+ })
+ .onConflictDoUpdate({
+ target: settings.key,
+ set: {
+ value: value as any, // Type cast needed
+ updatedAt: new Date(),
+ },
+ });
+ });
+}
+
+/**
+ * Delete a setting (restores to default value)
+ * @param key The setting key
+ * @param userId The ID of the user making the change
+ * @param reason Optional reason for the change
+ */
+export async function deleteSetting(
+ key: K,
+ userId: number,
+ reason?: string
+): Promise {
+ await db.transaction(async (tx) => {
+ // 1. Get current value to store as history
+ const currentSetting = await tx.query.settings.findFirst({
+ where: eq(settings.key, key),
+ columns: { value: true },
+ });
+
+ if (currentSetting) {
+ // 2. Add to history
+ await tx.insert(settingsHistory).values({
+ settingKey: key,
+ previousValue: currentSetting.value,
+ changedById: userId,
+ changeReason: reason ?? "Restored to default",
+ });
+
+ // 3. Delete the setting
+ await tx.delete(settings).where(eq(settings.key, key));
+ }
+ });
+}
+
+/**
+ * Get setting history for a specific setting
+ * @param key The setting key
+ * @returns Array of history entries
+ */
+export async function getSettingHistory(
+ key: K
+): Promise[]> {
+ const history = await db.query.settingsHistory.findMany({
+ where: eq(settingsHistory.settingKey, key),
+ orderBy: [desc(settingsHistory.changedAt)],
+ with: {
+ changedBy: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ });
+
+ return history.map((entry) => ({
+ id: entry.id,
+ settingKey: entry.settingKey as K,
+ previousValue: entry.previousValue as SettingValue,
+ changedById: entry.changedById,
+ changedAt: entry.changedAt,
+ changeReason: entry.changeReason,
+ }));
+}
+
+/**
+ * Initialize default settings if they don't exist
+ */
+export async function initializeDefaultSettings(): Promise {
+ const { DEFAULT_SETTINGS } = await import("@repo/types");
+
+ // Get all defined setting keys
+ const allKeys = Object.keys(DEFAULT_SETTINGS) as SettingKey[];
+
+ // Check which settings already exist in the database
+ const existingSettings = await db.query.settings.findMany({
+ columns: { key: true },
+ });
+ const existingKeys = new Set(existingSettings.map((s) => s.key));
+
+ // Filter out keys that already exist
+ const keysToCreate = allKeys.filter((key) => !existingKeys.has(key));
+
+ // Create missing settings with default values
+ if (keysToCreate.length > 0) {
+ await db.transaction(async (tx) => {
+ for (const key of keysToCreate) {
+ const setting = DEFAULT_SETTINGS[key];
+ await tx.insert(settings).values({
+ key,
+ value: setting.value,
+ description: setting.description,
+ });
+ }
+ });
+
+ console.log(`Initialized ${keysToCreate.length} default settings`);
+ }
+}
+
+/**
+ * Cached version of getSetting for use in React Server Components
+ */
+export const getCachedSetting = cache(getSetting);
+
+/**
+ * Cached version of getAllSettings for use in React Server Components
+ */
+export const getCachedAllSettings = cache(getAllSettings);
diff --git a/apps/web/src/lib/services/system.ts b/apps/web/src/lib/services/system.ts
index e85720d..ebae259 100644
--- a/apps/web/src/lib/services/system.ts
+++ b/apps/web/src/lib/services/system.ts
@@ -1,34 +1,91 @@
-import { db, users, wikiTags, wikiPages, assets, groups } from "@repo/db";
-import { sql } from "drizzle-orm";
+import {
+ db,
+ users,
+ wikiTags,
+ wikiPages,
+ assets,
+ groups,
+ sessions,
+} from "@repo/db";
+import { sql, isNotNull, gt } from "drizzle-orm";
/**
- * Tag service - handles all tag-related database operations
+ * System service - handles system-related operations
*/
export const systemService = {
getStats: async () => {
- const [userCount, pageCount, tagCount, assetCount, groupCount] =
- await Promise.all([
- db.select({ count: sql`COUNT(*)`.mapWith(Number) }).from(users),
- db
- .select({ count: sql`COUNT(*)`.mapWith(Number) })
- .from(wikiPages),
- db
- .select({ count: sql`COUNT(*)`.mapWith(Number) })
- .from(wikiTags),
- db
- .select({ count: sql`COUNT(*)`.mapWith(Number) })
- .from(assets),
- db
- .select({ count: sql`COUNT(*)`.mapWith(Number) })
- .from(groups),
- ]);
+ let dbStatus: "healthy" | "unhealthy" | "error" | "pending" = "pending";
+ let dbError: string | null = null;
+
+ try {
+ // Simple query to check DB connection
+ await db.execute(sql`SELECT 1`);
+ dbStatus = "healthy";
+ } catch (error) {
+ console.error("Database health check failed:", error);
+ dbStatus = "unhealthy";
+ dbError =
+ error instanceof Error ? error.message : "Unknown database error";
+ }
+
+ const results = await Promise.allSettled([
+ db.select({ count: sql`COUNT(*)`.mapWith(Number) }).from(users),
+ db
+ .select({ count: sql`COUNT(*)`.mapWith(Number) })
+ .from(wikiPages),
+ db
+ .select({ count: sql`COUNT(*)`.mapWith(Number) })
+ .from(wikiTags),
+ db.select({ count: sql`COUNT(*)`.mapWith(Number) }).from(assets),
+ db.select({ count: sql`COUNT(*)`.mapWith(Number) }).from(groups),
+ db
+ .select({ count: sql`COUNT(*)`.mapWith(Number) })
+ .from(wikiPages)
+ .where(isNotNull(wikiPages.lockedById)),
+ db
+ .select({ count: sql`COUNT(*)`.mapWith(Number) })
+ .from(sessions)
+ .where(gt(sessions.expires, new Date())),
+ ]);
+
+ // Simplified helper to extract count directly from the result array
+ const getCountFromResult = (
+ result: PromiseSettledResult<{ count: number }[]>
+ ) => {
+ if (
+ result.status === "fulfilled" &&
+ result.value &&
+ result.value.length > 0 &&
+ result.value[0]
+ ) {
+ return result.value[0].count ?? 0;
+ }
+ if (result.status === "rejected") {
+ console.error("Failed to fetch count:", result.reason);
+ }
+ return 0;
+ };
+
+ const [
+ userCountResult,
+ pageCountResult,
+ tagCountResult,
+ assetCountResult,
+ groupCountResult,
+ lockedPagesCountResult,
+ activeSessionCountResult,
+ ] = results;
const stats = {
- userCount: userCount[0]?.count ?? 0,
- pageCount: pageCount[0]?.count ?? 0,
- tagCount: tagCount[0]?.count ?? 0,
- assetCount: assetCount[0]?.count ?? 0,
- groupCount: groupCount[0]?.count ?? 0,
+ dbStatus,
+ dbError,
+ userCount: getCountFromResult(userCountResult),
+ pageCount: getCountFromResult(pageCountResult),
+ tagCount: getCountFromResult(tagCountResult),
+ assetCount: getCountFromResult(assetCountResult),
+ groupCount: getCountFromResult(groupCountResult),
+ lockedPagesCount: getCountFromResult(lockedPagesCountResult),
+ activeSessionCount: getCountFromResult(activeSessionCountResult),
};
return stats;
},
diff --git a/apps/web/src/lib/services/users.ts b/apps/web/src/lib/services/users.ts
index de65c0c..4d57896 100644
--- a/apps/web/src/lib/services/users.ts
+++ b/apps/web/src/lib/services/users.ts
@@ -1,6 +1,9 @@
import { db } from "@repo/db";
import { users } from "@repo/db";
import { sql, eq } from "drizzle-orm";
+import { logger } from "../utils/logger";
+import { TRPCError } from "@trpc/server";
+import { getPaginationParams, PaginationInput } from "../utils/pagination";
/**
* User service - handles all user-related database operations
@@ -28,8 +31,12 @@ export const userService = {
/**
* Get a list of all users
+ * @deprecated Implement pagination and search
*/
async getAll() {
+ logger.warn(
+ "Deprecated procedure getAll called. Use getPaginated instead."
+ );
return db.query.users.findMany({
with: {
userGroups: {
@@ -41,6 +48,28 @@ export const userService = {
});
},
+ /**
+ * Get a paginated list of users
+ * @param page - The page number to fetch
+ * @param pageSize - The number of users per page
+ * @param search - Optional search query
+ * @returns A paginated list of users
+ */
+ async getPaginated(
+ pagination: PaginationInput,
+ options?: { search?: string }
+ ) {
+ const { take, skip } = getPaginationParams(pagination);
+ void take;
+ void skip;
+ void options;
+
+ throw new TRPCError({
+ code: "NOT_IMPLEMENTED",
+ message: "getPaginated is not implemented",
+ });
+ },
+
/**
* Get user groups by ID
*/
diff --git a/apps/web/src/lib/utils/settings.ts b/apps/web/src/lib/utils/settings.ts
new file mode 100644
index 0000000..2419b59
--- /dev/null
+++ b/apps/web/src/lib/utils/settings.ts
@@ -0,0 +1,83 @@
+import {
+ type SettingKey,
+ type SettingValue,
+ getDefaultSetting,
+} from "@repo/types";
+import { getSetting, getAllSettings } from "../services/settings";
+import { cache } from "react";
+
+/**
+ * Get a single setting value with caching for server components
+ * Falls back to default value if setting doesn't exist in database
+ *
+ * @param key The setting key to retrieve
+ * @returns The setting value
+ */
+export const getSettingValue = cache(
+ async (key: K): Promise> => {
+ try {
+ return await getSetting(key);
+ } catch (error) {
+ console.error(`Error fetching setting "${key}":`, error);
+ return getDefaultSetting(key);
+ }
+ }
+);
+
+/**
+ * Get multiple setting values with caching for server components
+ * Falls back to default values for any settings that don't exist in database
+ *
+ * @param keys Array of setting keys to retrieve
+ * @returns Object with requested settings
+ */
+export const getSettingValues = cache(
+ async (
+ keys: K[]
+ ): Promise>> => {
+ try {
+ // Get all settings for efficiency
+ const allSettings = await getAllSettings();
+
+ // Create result object with values from database or defaults
+ return keys.reduce(
+ (acc, key) => {
+ // Use database value if available, otherwise use default
+ if (key in allSettings) {
+ acc[key] = allSettings[
+ key as keyof typeof allSettings
+ ] as SettingValue;
+ } else {
+ acc[key] = getDefaultSetting(key);
+ }
+ return acc;
+ },
+ {} as Record>
+ );
+ } catch (error) {
+ console.error(`Error fetching settings:`, error);
+
+ // Fall back to all defaults
+ return keys.reduce(
+ (acc, key) => {
+ acc[key] = getDefaultSetting(key);
+ return acc;
+ },
+ {} as Record>
+ );
+ }
+ }
+);
+
+/**
+ * Example usage in a server component:
+ *
+ * export default async function Page() {
+ * const siteTitle = await getSettingValue('site.title');
+ * // or get multiple settings at once
+ * const { 'site.title': title, 'site.description': description } =
+ * await getSettingValues(['site.title', 'site.description']);
+ *
+ * return {title} ;
+ * }
+ */
diff --git a/apps/web/src/server/routers/admin/groups.ts b/apps/web/src/server/routers/admin/groups.ts
index 37e4be0..d8cf99f 100644
--- a/apps/web/src/server/routers/admin/groups.ts
+++ b/apps/web/src/server/routers/admin/groups.ts
@@ -219,18 +219,13 @@ export const groupsRouter = router({
.input(
z.object({
groupId: z.number(),
- permissions: z.array(
- z.object({
- module: z.string(),
- isAllowed: z.boolean(),
- })
- ),
+ moduleIds: z.array(z.number()),
})
)
.mutation(async ({ input }) => {
const result = await dbService.groups.addModulePermissions(
input.groupId,
- input.permissions.map((p) => p.module)
+ input.moduleIds
);
return result;
}),
@@ -243,18 +238,13 @@ export const groupsRouter = router({
.input(
z.object({
groupId: z.number(),
- permissions: z.array(
- z.object({
- action: z.string(),
- isAllowed: z.boolean(),
- })
- ),
+ actionIds: z.array(z.number()),
})
)
.mutation(async ({ input }) => {
const result = await dbService.groups.addActionPermissions(
input.groupId,
- input.permissions.map((p) => p.action)
+ input.actionIds
);
return result;
}),
@@ -297,9 +287,22 @@ export const groupsRouter = router({
})
)
.mutation(async ({ input }) => {
+ const allModules = await dbService.modules.getAll();
+ const moduleNameToIdMap = new Map(allModules.map((m) => [m.name, m.id]));
+
+ const moduleIdsToRemove = input.modules
+ .map((name) => moduleNameToIdMap.get(name))
+ .filter((id): id is number => id !== undefined);
+
+ if (moduleIdsToRemove.length === 0 && input.modules.length > 0) {
+ logger.warn(
+ `Could not find any module IDs for names: ${input.modules.join(", ")}`
+ );
+ }
+
const result = await dbService.groups.removeModulePermissions(
input.groupId,
- input.modules
+ moduleIdsToRemove
);
return result;
}),
@@ -316,9 +319,22 @@ export const groupsRouter = router({
})
)
.mutation(async ({ input }) => {
+ const allActions = await dbService.actions.getAll();
+ const actionNameToIdMap = new Map(allActions.map((a) => [a.name, a.id]));
+
+ const actionIdsToRemove = input.actions
+ .map((name) => actionNameToIdMap.get(name))
+ .filter((id): id is number => id !== undefined);
+
+ if (actionIdsToRemove.length === 0 && input.actions.length > 0) {
+ logger.warn(
+ `Could not find any action IDs for names: ${input.actions.join(", ")}`
+ );
+ }
+
const result = await dbService.groups.removeActionPermissions(
input.groupId,
- input.actions
+ actionIdsToRemove
);
return result;
}),
diff --git a/apps/web/src/server/routers/admin/index.ts b/apps/web/src/server/routers/admin/index.ts
index 79abe3f..62aa43a 100644
--- a/apps/web/src/server/routers/admin/index.ts
+++ b/apps/web/src/server/routers/admin/index.ts
@@ -3,6 +3,8 @@ import { groupsRouter } from "./groups";
import { permissionsRouter } from "./permissions";
import { usersRouter } from "./users";
import { systemRouter } from "./system";
+import { adminWikiRouter } from "./wiki";
+import { settingsRouter } from "./settings";
/**
* Main router for admin-specific procedures.
* Sub-routers for different admin areas (e.g., users, groups) should be merged here.
@@ -12,6 +14,8 @@ export const adminRouter = router({
permissions: permissionsRouter,
users: usersRouter,
system: systemRouter,
+ wiki: adminWikiRouter,
+ settings: settingsRouter,
});
export type AdminRouter = typeof adminRouter;
diff --git a/apps/web/src/server/routers/admin/permissions.ts b/apps/web/src/server/routers/admin/permissions.ts
index 68d7e23..9ddc821 100644
--- a/apps/web/src/server/routers/admin/permissions.ts
+++ b/apps/web/src/server/routers/admin/permissions.ts
@@ -2,6 +2,13 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, permissionProtectedProcedure } from "~/server";
import { dbService } from "~/lib/services";
+// Import validation functions
+import {
+ validatePermissionsDatabase,
+ fixPermissionsDatabase,
+ logValidationResults,
+} from "~/lib/permissions/validation";
+import { logger } from "@repo/logger";
export const permissionsRouter = router({
/**
@@ -19,8 +26,7 @@ export const permissionsRouter = router({
*/
getModules: permissionProtectedProcedure("system:permissions:read").query(
async () => {
- const permissions = await dbService.permissions.getAll();
- const modules = [...new Set(permissions.map((p) => p.module))];
+ const modules = await dbService.modules.getAll();
return modules;
}
),
@@ -30,8 +36,7 @@ export const permissionsRouter = router({
*/
getActions: permissionProtectedProcedure("system:permissions:read").query(
async () => {
- const permissions = await dbService.permissions.getAll();
- const actions = [...new Set(permissions.map((p) => p.action))];
+ const actions = await dbService.actions.getAll();
return actions;
}
),
@@ -58,58 +63,102 @@ export const permissionsRouter = router({
create: permissionProtectedProcedure("system:settings:update")
.input(
z.object({
- name: z.string().min(3).max(100),
+ moduleId: z.number(),
+ resource: z.string().min(1).max(50),
+ actionId: z.number(),
description: z.string().optional(),
- module: z.string().min(2).max(50),
- action: z.string().min(2).max(50),
})
)
.mutation(async ({ input }) => {
- const newPermission = await dbService.permissions.create({
- ...input,
- resource: input.name,
- });
+ const newPermission = await dbService.permissions.create(input);
return newPermission;
}),
/**
- * Update a permission
+ * Update a permission (very restricted operation)
*/
update: permissionProtectedProcedure("system:settings:update")
.input(
z.object({
id: z.number(),
- name: z.string().min(3).max(100).optional(),
- description: z.string().optional(),
- module: z.string().min(2).max(50).optional(),
- action: z.string().min(2).max(50).optional(),
+ moduleId: z.number().optional(),
+ resource: z.string().min(1).max(50).optional(),
+ actionId: z.number().optional(),
+ description: z.string().nullish(), // Allow null or undefined from client
})
)
.mutation(async ({ input }) => {
- const { id, ...data } = input;
- const permission = await dbService.permissions.update(id, data);
- if (!permission) {
+ const { id, ...updateData } = input;
+ // Convert null description to undefined for the service call
+ const serviceUpdateData = {
+ ...updateData,
+ description:
+ updateData.description === null ? undefined : updateData.description,
+ };
+ const updatedPermission = await dbService.permissions.update(
+ id,
+ serviceUpdateData // Pass the corrected data
+ );
+ if (!updatedPermission) {
throw new TRPCError({
code: "NOT_FOUND",
- message: "Permission not found",
+ message: "Permission not found for update",
});
}
- return permission;
+ return updatedPermission;
}),
/**
- * Delete a permission
+ * Delete a permission (very restricted operation)
*/
delete: permissionProtectedProcedure("system:settings:update")
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
- const permission = await dbService.permissions.delete(input.id);
- if (!permission) {
+ const deletedPermission = await dbService.permissions.delete(input.id);
+ if (!deletedPermission) {
throw new TRPCError({
code: "NOT_FOUND",
- message: "Permission not found",
+ message: "Permission not found for deletion",
});
}
- return { success: true };
+ return deletedPermission;
+ }),
+
+ /**
+ * Validate permissions registry against database
+ */
+ validate: permissionProtectedProcedure("system:settings:read").mutation(
+ async () => {
+ logger.info("Running permissions validation...");
+ const validationResult = await validatePermissionsDatabase();
+ logValidationResults(validationResult); // Log details on the server
+ // Return a summary to the client
+ return {
+ isValid: validationResult.isValid,
+ missingCount: validationResult.missing.length,
+ extrasCount: validationResult.extras.length,
+ mismatchedCount: validationResult.mismatched.length,
+ };
+ }
+ ),
+
+ /**
+ * Fix permissions in the database based on the registry
+ */
+ fix: permissionProtectedProcedure("system:settings:update")
+ .input(
+ z.object({
+ removeExtras: z.boolean().default(false),
+ })
+ )
+ .mutation(async ({ input }) => {
+ logger.info(
+ `Running permissions fix (removeExtras: ${input.removeExtras})...`
+ );
+ const fixResult = await fixPermissionsDatabase(input.removeExtras);
+ logger.info(
+ `Permissions fix completed: ${fixResult.added} added, ${fixResult.updated} updated, ${fixResult.removed} removed.`
+ );
+ return fixResult; // Return counts to the client
}),
});
diff --git a/apps/web/src/server/routers/admin/settings.ts b/apps/web/src/server/routers/admin/settings.ts
new file mode 100644
index 0000000..63f574f
--- /dev/null
+++ b/apps/web/src/server/routers/admin/settings.ts
@@ -0,0 +1,193 @@
+import { z } from "zod";
+import { router, permissionProtectedProcedure } from "~/server";
+import {
+ getAllSettings,
+ getSetting,
+ getSettingHistory,
+ updateSetting,
+ deleteSetting,
+ initializeDefaultSettings,
+} from "~/lib/services/settings";
+import { DEFAULT_SETTINGS, type SettingKey } from "@repo/types";
+
+// Create a Zod schema for setting keys
+const SettingKeySchema = z.enum(
+ Object.keys(DEFAULT_SETTINGS) as [string, ...string[]]
+);
+
+/**
+ * tRPC router for settings management in the admin section
+ */
+export const settingsRouter = router({
+ /**
+ * Get all settings
+ */
+ getAll: permissionProtectedProcedure("system:settings:read").query(
+ async () => {
+ const settings = await getAllSettings();
+ return settings;
+ }
+ ),
+
+ /**
+ * Get a specific setting by key
+ */
+ get: permissionProtectedProcedure("system:settings:read")
+ .input(
+ z.object({
+ key: SettingKeySchema,
+ })
+ )
+ .query(async ({ input }) => {
+ const value = await getSetting(input.key as SettingKey);
+ return {
+ key: input.key,
+ value,
+ meta: DEFAULT_SETTINGS[input.key as SettingKey],
+ };
+ }),
+
+ /**
+ * Update a setting
+ */
+ update: permissionProtectedProcedure("system:settings:update")
+ .input(
+ z.object({
+ key: SettingKeySchema,
+ value: z.any(), // We'll validate the value type in the resolver
+ reason: z.string().optional(),
+ })
+ )
+ .mutation(async ({ input, ctx }) => {
+ const { key, value, reason } = input;
+ const typedKey = key as SettingKey;
+
+ // Validate the value matches the expected type
+ const expectedType = DEFAULT_SETTINGS[typedKey].type;
+
+ // Simple type validation
+ const valueType = typeof value;
+ if (
+ (expectedType === "string" && valueType !== "string") ||
+ (expectedType === "number" && valueType !== "number") ||
+ (expectedType === "boolean" && valueType !== "boolean") ||
+ // For select, we need to check if the value is in the options
+ (expectedType === "select" &&
+ !(
+ "options" in DEFAULT_SETTINGS[typedKey] &&
+ (DEFAULT_SETTINGS[typedKey].options as string[]).includes(value)
+ )) ||
+ // For JSON, we need to check if it's an object
+ (expectedType === "json" && (valueType !== "object" || value === null))
+ ) {
+ throw new Error(
+ `Invalid value type for setting ${key}. Expected ${expectedType}, got ${valueType}`
+ );
+ }
+
+ await updateSetting(
+ typedKey,
+ value,
+ parseInt(ctx.session.user.id),
+ reason
+ );
+
+ return { success: true };
+ }),
+
+ /**
+ * Reset a setting to its default value
+ */
+ reset: permissionProtectedProcedure("system:settings:update")
+ .input(
+ z.object({
+ key: SettingKeySchema,
+ reason: z.string().optional(),
+ })
+ )
+ .mutation(async ({ input, ctx }) => {
+ const { key, reason } = input;
+ const typedKey = key as SettingKey;
+
+ // First delete the current setting
+ await deleteSetting(
+ typedKey,
+ parseInt(ctx.session.user.id),
+ reason || "Reset to default"
+ );
+
+ return { success: true };
+ }),
+
+ /**
+ * Get the history of changes for a setting
+ */
+ getHistory: permissionProtectedProcedure("system:settings:read")
+ .input(
+ z.object({
+ key: SettingKeySchema,
+ })
+ )
+ .query(async ({ input }) => {
+ const history = await getSettingHistory(input.key as SettingKey);
+ return history;
+ }),
+
+ /**
+ * Get settings by category
+ */
+ getByCategory: permissionProtectedProcedure("system:settings:read")
+ .input(
+ z.object({
+ category: z.enum([
+ "general",
+ "auth",
+ "appearance",
+ "editor",
+ "search",
+ "advanced",
+ ]),
+ })
+ )
+ .query(async ({ input }) => {
+ // Get all settings
+ const allSettings = await getAllSettings();
+
+ // Filter settings by category and add metadata
+ const categorySettings = Object.entries(DEFAULT_SETTINGS)
+ .filter(([_, setting]) => {
+ void _;
+ // Type assertion to access category property
+ const typedSetting = setting;
+ return typedSetting.category === input.category;
+ })
+ .map(([key]) => {
+ const typedKey = key as SettingKey;
+
+ // Use getSetting to get the value with proper defaults if not in database
+ const defaultValue = DEFAULT_SETTINGS[typedKey].value;
+ const value =
+ typedKey in allSettings
+ ? allSettings[typedKey as keyof typeof allSettings]
+ : defaultValue;
+
+ return {
+ key: typedKey,
+ value,
+ meta: DEFAULT_SETTINGS[typedKey],
+ };
+ });
+
+ return categorySettings;
+ }),
+
+ /**
+ * Initialize default settings
+ */
+ initialize: permissionProtectedProcedure("system:settings:update").mutation(
+ async ({ ctx }) => {
+ await initializeDefaultSettings();
+ return { success: true };
+ }
+ ),
+});
diff --git a/apps/web/src/server/routers/admin/system.ts b/apps/web/src/server/routers/admin/system.ts
index 2b19d96..c26238c 100644
--- a/apps/web/src/server/routers/admin/system.ts
+++ b/apps/web/src/server/routers/admin/system.ts
@@ -1,5 +1,3 @@
-import { z } from "zod";
-import { TRPCError } from "@trpc/server";
import { router, permissionProtectedProcedure } from "~/server";
import { dbService } from "~/lib/services";
diff --git a/apps/web/src/server/routers/admin/users.ts b/apps/web/src/server/routers/admin/users.ts
index c2715c4..0846574 100644
--- a/apps/web/src/server/routers/admin/users.ts
+++ b/apps/web/src/server/routers/admin/users.ts
@@ -7,6 +7,7 @@ export const usersRouter = router({
/**
* Get all users
* @requires system:users:read
+ * @deprecated Implement pagination and search
*/
getAll: permissionProtectedProcedure("system:users:read").query(async () => {
const users = await dbService.users.getAll();
diff --git a/apps/web/src/server/routers/admin/wiki.ts b/apps/web/src/server/routers/admin/wiki.ts
new file mode 100644
index 0000000..0bf6bb4
--- /dev/null
+++ b/apps/web/src/server/routers/admin/wiki.ts
@@ -0,0 +1,197 @@
+import { z } from "zod";
+import { permissionProtectedProcedure, router } from "~/server";
+import { db, wikiPages } from "@repo/db";
+import { dbService } from "~/lib/services";
+import { logger } from "@repo/logger";
+import { formatDistanceToNow } from "date-fns";
+import { TRPCError } from "@trpc/server";
+import { desc, gt, ilike, or, and, isNotNull } from "drizzle-orm";
+
+export const adminWikiRouter = router({
+ /**
+ * Get recently updated wiki pages for the admin dashboard.
+ */
+ getRecent: permissionProtectedProcedure("admin:wiki:read")
+ .input(
+ z.object({
+ limit: z.number().min(1).max(20).default(5),
+ })
+ )
+ .query(async ({ input }) => {
+ try {
+ const recentPages = await dbService.wiki.getRecentPages(input.limit);
+ const formattedPages = recentPages.map((page) => ({
+ ...page,
+ updatedAtRelative: page.updatedAt
+ ? formatDistanceToNow(page.updatedAt, { addSuffix: true })
+ : "Never",
+ }));
+ return formattedPages;
+ } catch (error) {
+ logger.error("Failed to fetch recent wiki pages for admin:", error);
+ return [];
+ }
+ }),
+
+ /**
+ * List wiki pages for the admin management table.
+ */
+ list: permissionProtectedProcedure("admin:wiki:read")
+ .input(
+ z.object({
+ limit: z.number().min(1).max(100).default(20),
+ cursor: z.number().nullish(), // page ID cursor for pagination
+ search: z.string().optional(),
+ sortBy: z
+ .enum(["title", "path", "updatedAt", "createdAt"])
+ .optional()
+ .default("updatedAt"),
+ sortOrder: z.enum(["asc", "desc"]).optional().default("desc"),
+ })
+ )
+ .query(async ({ input }) => {
+ const { limit, cursor, search, sortBy, sortOrder } = input;
+
+ // Determine the sort column and order
+ let orderByColumn;
+ switch (sortBy) {
+ case "title":
+ orderByColumn = wikiPages.title;
+ break;
+ case "path":
+ orderByColumn = wikiPages.path;
+ break;
+ case "createdAt":
+ orderByColumn = wikiPages.createdAt;
+ break;
+ case "updatedAt":
+ default:
+ orderByColumn = wikiPages.updatedAt;
+ break;
+ }
+ const orderBy = sortOrder === "asc" ? orderByColumn : desc(orderByColumn);
+
+ // Build conditions: cursor-based pagination
+ let whereConditions = cursor ? gt(wikiPages.id, cursor) : undefined;
+
+ // Add search condition if provided
+ if (search) {
+ const searchCondition = or(
+ ilike(wikiPages.title, `%${search}%`),
+ ilike(wikiPages.path, `%${search}%`)
+ // Add more fields to search if needed (e.g., content, tags)
+ );
+ whereConditions = whereConditions
+ ? and(whereConditions, searchCondition)
+ : searchCondition;
+ }
+
+ try {
+ const items = await db.query.wikiPages.findMany({
+ columns: {
+ id: true,
+ path: true,
+ title: true,
+ isPublished: true,
+ createdAt: true,
+ updatedAt: true,
+ lockedById: true, // Include to check lock status
+ lockExpiresAt: true,
+ // Exclude large fields unless specifically needed
+ content: false,
+ renderedHtml: false,
+ search: false,
+ },
+ where: whereConditions,
+ orderBy: [orderBy, desc(wikiPages.id)], // Secondary sort by ID for stable pagination
+ limit: limit + 1, // Fetch one extra to determine if there's a next page
+ with: {
+ createdBy: {
+ columns: { id: true, name: true },
+ },
+ updatedBy: {
+ columns: { id: true, name: true },
+ },
+ lockedBy: {
+ columns: { id: true, name: true }, // Include lock owner info
+ },
+ },
+ });
+
+ let nextCursor: typeof cursor | undefined = undefined;
+ if (items.length > limit) {
+ const nextItem = items.pop(); // Remove the extra item
+ nextCursor = nextItem!.id; // Use its ID as the next cursor
+ }
+
+ // Format dates and add relative time
+ const formattedItems = items.map((item) => ({
+ ...item,
+ createdAtRelative: item.createdAt
+ ? formatDistanceToNow(item.createdAt, { addSuffix: true })
+ : "Unknown",
+ updatedAtRelative: item.updatedAt
+ ? formatDistanceToNow(item.updatedAt, { addSuffix: true })
+ : "Never",
+ }));
+
+ return {
+ items: formattedItems,
+ nextCursor,
+ };
+ } catch (error) {
+ logger.error("Failed to list wiki pages for admin:", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to fetch wiki pages",
+ });
+ }
+ }),
+
+ /**
+ * Get currently locked wiki pages for the admin dashboard.
+ */
+ listLocked: permissionProtectedProcedure("admin:wiki:read")
+ .input(
+ z.object({
+ limit: z.number().min(1).max(20).default(5),
+ })
+ )
+ .query(async ({ input }) => {
+ const { limit } = input;
+ try {
+ const lockedPages = await db.query.wikiPages.findMany({
+ columns: {
+ id: true,
+ path: true,
+ title: true,
+ lockedAt: true, // Include lock timestamp
+ },
+ where: isNotNull(wikiPages.lockedById), // Filter for locked pages
+ orderBy: [desc(wikiPages.lockedAt)], // Order by most recently locked
+ limit: limit,
+ with: {
+ lockedBy: {
+ // Eager load the user who locked the page
+ columns: { id: true, name: true },
+ },
+ },
+ });
+
+ // Format dates and add relative time
+ const formattedPages = lockedPages.map((page) => ({
+ ...page,
+ // Ensure lockedAt is not null before formatting
+ lockedAtRelative: page.lockedAt
+ ? formatDistanceToNow(page.lockedAt, { addSuffix: true })
+ : "Unknown",
+ }));
+
+ return formattedPages;
+ } catch (error) {
+ logger.error("Failed to fetch locked wiki pages for admin:", error);
+ // Return empty array on error for dashboard resilience
+ return [];
+ }
+ }),
+});
diff --git a/apps/web/src/server/routers/auth.ts b/apps/web/src/server/routers/auth.ts
index dcda8ec..1178bd7 100644
--- a/apps/web/src/server/routers/auth.ts
+++ b/apps/web/src/server/routers/auth.ts
@@ -26,21 +26,32 @@ export const authRouter = router({
}
// Get all permissions for the current user
- const permissions = await authorizationService.getUserPermissions(userId);
+ const permissionsWithRelations =
+ await authorizationService.getUserPermissions(userId);
// Return permissions in a convenient format for the frontend
return {
- // Return the full permission objects
- permissions,
+ // Return the full permission objects (including relations if needed by client)
+ permissions: permissionsWithRelations,
// Return an array of permission names (e.g. ["wiki:page:read", "wiki:page:create"])
- permissionNames: permissions.map((p) => p.name as PermissionIdentifier),
+ permissionNames: permissionsWithRelations.map((p) => {
+ // Construct name from relations. Handle potential nulls defensively.
+ const moduleName = p.module?.name ?? "unknown-module";
+ const actionName = p.action?.name ?? "unknown-action";
+ return `${moduleName}:${p.resource}:${actionName}` as PermissionIdentifier;
+ }),
// Return a map of permissions for easy checking (e.g. {"wiki:page:read": true})
- permissionMap: permissions.reduce(
+ permissionMap: permissionsWithRelations.reduce(
(acc, p) => {
- if (validatePermissionId(p.name)) {
- acc[p.name] = true;
+ const moduleName = p.module?.name;
+ const actionName = p.action?.name;
+ if (moduleName && actionName) {
+ const name = `${moduleName}:${p.resource}:${actionName}`;
+ if (validatePermissionId(name)) {
+ acc[name as PermissionIdentifier] = true;
+ }
}
return acc;
},
@@ -113,27 +124,34 @@ export const authRouter = router({
}
// Guest users have userId undefined
- const permissions =
+ const permissionsWithRelations =
await authorizationService.getUserPermissions(undefined);
// Collect permission names
- const permissionNames = permissions.map(
- (p) => `${p.module}:${p.resource}:${p.action}` as PermissionIdentifier
- );
+ const permissionNames = permissionsWithRelations.map((p) => {
+ const moduleName = p.module?.name ?? "unknown-module";
+ const actionName = p.action?.name ?? "unknown-action";
+ return `${moduleName}:${p.resource}:${actionName}` as PermissionIdentifier;
+ });
// Create a map for easy lookup
- const permissionMap = permissions.reduce(
+ const permissionMap = permissionsWithRelations.reduce(
(acc, permission) => {
- const id =
- `${permission.module}:${permission.resource}:${permission.action}` as PermissionIdentifier;
- acc[id] = true;
+ const moduleName = permission.module?.name;
+ const actionName = permission.action?.name;
+ if (moduleName && actionName) {
+ const name = `${moduleName}:${permission.resource}:${actionName}`;
+ if (validatePermissionId(name)) {
+ acc[name as PermissionIdentifier] = true;
+ }
+ }
return acc;
},
{} as Record
);
return {
- permissions,
+ permissions: permissionsWithRelations,
permissionNames,
permissionMap,
};
diff --git a/apps/web/src/server/routers/index.ts b/apps/web/src/server/routers/index.ts
index f0e9b61..0a7326b 100644
--- a/apps/web/src/server/routers/index.ts
+++ b/apps/web/src/server/routers/index.ts
@@ -1,4 +1,4 @@
-import { router } from "..";
+import { publicProcedure, router } from "..";
import { wikiRouter } from "./wiki";
import { usersRouter } from "./users";
import { searchRouter } from "./search";
@@ -6,8 +6,18 @@ import { assetsRouter } from "./assets";
import { authRouter } from "./auth";
import { tagsRouter } from "./tags";
import { adminRouter } from "./admin";
+import { TRPCError } from "@trpc/server";
export const appRouter = router({
+ ping: publicProcedure.query(() => {
+ // throw new TRPCError({
+ // code: "NOT_IMPLEMENTED",
+ // message: "Not implemented",
+ // });
+
+ return "pong";
+ }),
+
admin: adminRouter,
wiki: wikiRouter,
users: usersRouter,
diff --git a/package.json b/package.json
index 085a935..7474d47 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"db:seed": "turbo run db:seed",
"db:setup": "turbo run db:setup",
"db:create": "SKIP_DEVELOPER_SEEDS=true turbo run db:setup",
+ "db:studio": "pnpm run -F @repo/db db:studio",
"clean": "turbo run clean",
"fullclean": "turbo run fullclean && rm -rf node_modules .turbo && rm -f pnpm-lock.yaml"
},
diff --git a/packages/db/drizzle/0000_light_sumo.sql b/packages/db/drizzle/0000_silly_iceman.sql
similarity index 85%
rename from packages/db/drizzle/0000_light_sumo.sql
rename to packages/db/drizzle/0000_silly_iceman.sql
index d3d56d8..d8438a8 100644
--- a/packages/db/drizzle/0000_light_sumo.sql
+++ b/packages/db/drizzle/0000_silly_iceman.sql
@@ -17,6 +17,14 @@ CREATE TABLE "accounts" (
"updated_at" timestamp DEFAULT now()
);
--> statement-breakpoint
+CREATE TABLE "actions" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "name" varchar(50) NOT NULL,
+ "description" text,
+ "created_at" timestamp DEFAULT now(),
+ CONSTRAINT "actions_name_unique" UNIQUE("name")
+);
+--> statement-breakpoint
CREATE TABLE "assets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(255),
@@ -37,16 +45,16 @@ CREATE TABLE "assets_to_pages" (
--> statement-breakpoint
CREATE TABLE "group_action_permissions" (
"group_id" integer NOT NULL,
- "action" varchar(50) NOT NULL,
+ "action_id" integer NOT NULL,
"created_at" timestamp DEFAULT now(),
- CONSTRAINT "group_action_permissions_group_id_action_pk" PRIMARY KEY("group_id", "action")
+ CONSTRAINT "group_action_permissions_group_id_action_id_pk" PRIMARY KEY("group_id", "action_id")
);
--> statement-breakpoint
CREATE TABLE "group_module_permissions" (
"group_id" integer NOT NULL,
- "module" varchar(50) NOT NULL,
+ "module_id" integer NOT NULL,
"created_at" timestamp DEFAULT now(),
- CONSTRAINT "group_module_permissions_group_id_module_pk" PRIMARY KEY("group_id", "module")
+ CONSTRAINT "group_module_permissions_group_id_module_id_pk" PRIMARY KEY("group_id", "module_id")
);
--> statement-breakpoint
CREATE TABLE "group_permissions" (
@@ -68,6 +76,14 @@ CREATE TABLE "groups" (
CONSTRAINT "groups_name_unique" UNIQUE("name")
);
--> statement-breakpoint
+CREATE TABLE "modules" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "name" varchar(50) NOT NULL,
+ "description" text,
+ "created_at" timestamp DEFAULT now(),
+ CONSTRAINT "modules_name_unique" UNIQUE("name")
+);
+--> statement-breakpoint
CREATE TABLE "page_permissions" (
"id" serial PRIMARY KEY NOT NULL,
"page_id" integer NOT NULL,
@@ -79,13 +95,11 @@ CREATE TABLE "page_permissions" (
--> statement-breakpoint
CREATE TABLE "permissions" (
"id" serial PRIMARY KEY NOT NULL,
- "module" varchar(50) NOT NULL,
+ "module_id" integer NOT NULL,
"resource" varchar(50) NOT NULL,
- "action" varchar(50) NOT NULL,
- "name" varchar(100) GENERATED ALWAYS AS ("module" || ':' || "resource" || ':' || "action") STORED NOT NULL,
+ "action_id" integer NOT NULL,
"description" text,
- "created_at" timestamp DEFAULT now(),
- CONSTRAINT "permissions_name_unique" UNIQUE("name")
+ "created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "sessions" (
@@ -187,9 +201,15 @@ ADD CONSTRAINT "assets_to_pages_page_id_wiki_pages_id_fk" FOREIGN KEY ("page_id"
ALTER TABLE "group_action_permissions"
ADD CONSTRAINT "group_action_permissions_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
+ALTER TABLE "group_action_permissions"
+ADD CONSTRAINT "group_action_permissions_action_id_actions_id_fk" FOREIGN KEY ("action_id") REFERENCES "public"."actions"("id") ON DELETE no action ON UPDATE no action;
+--> statement-breakpoint
ALTER TABLE "group_module_permissions"
ADD CONSTRAINT "group_module_permissions_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
+ALTER TABLE "group_module_permissions"
+ADD CONSTRAINT "group_module_permissions_module_id_modules_id_fk" FOREIGN KEY ("module_id") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;
+--> statement-breakpoint
ALTER TABLE "group_permissions"
ADD CONSTRAINT "group_permissions_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
@@ -205,6 +225,12 @@ ADD CONSTRAINT "page_permissions_group_id_groups_id_fk" FOREIGN KEY ("group_id")
ALTER TABLE "page_permissions"
ADD CONSTRAINT "page_permissions_permission_id_permissions_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."permissions"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
+ALTER TABLE "permissions"
+ADD CONSTRAINT "permissions_module_id_modules_id_fk" FOREIGN KEY ("module_id") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;
+--> statement-breakpoint
+ALTER TABLE "permissions"
+ADD CONSTRAINT "permissions_action_id_actions_id_fk" FOREIGN KEY ("action_id") REFERENCES "public"."actions"("id") ON DELETE no action ON UPDATE no action;
+--> statement-breakpoint
ALTER TABLE "sessions"
ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
--> statement-breakpoint
@@ -237,33 +263,33 @@ ADD CONSTRAINT "wiki_pages_locked_by_id_users_id_fk" FOREIGN KEY ("locked_by_id"
--> statement-breakpoint
CREATE INDEX "asset_page_idx" ON "assets_to_pages" USING btree ("asset_id", "page_id");
--> statement-breakpoint
-CREATE INDEX "group_action_permissions_idx" ON "group_action_permissions" USING btree ("group_id", "action");
+CREATE INDEX "group_action_permissions_idx" ON "group_action_permissions" USING btree ("group_id", "action_id");
--> statement-breakpoint
-CREATE INDEX "group_module_permissions_idx" ON "group_module_permissions" USING btree ("group_id", "module");
+CREATE INDEX "group_module_permissions_idx" ON "group_module_permissions" USING btree ("group_id", "module_id");
--> statement-breakpoint
CREATE INDEX "group_permission_idx" ON "group_permissions" USING btree ("group_id", "permission_id");
--> statement-breakpoint
CREATE INDEX "page_group_perm_idx" ON "page_permissions" USING btree ("page_id", "permission_id", "group_id");
--> statement-breakpoint
+CREATE UNIQUE INDEX "permission_uniq_idx" ON "permissions" USING btree ("module_id", "resource", "action_id");
+--> statement-breakpoint
CREATE INDEX "user_group_idx" ON "user_groups" USING btree ("user_id", "group_id");
--> statement-breakpoint
CREATE INDEX "email_idx" ON "users" USING btree ("email");
--> statement-breakpoint
+CREATE INDEX "verification_token_idx" ON "verification_tokens" USING btree ("identifier", "token");
+--> statement-breakpoint
CREATE INDEX "idx_search" ON "wiki_pages" USING gin ("search");
--> statement-breakpoint
CREATE INDEX "trgm_idx_title" ON "wiki_pages" USING btree ("title");
---> statement-breakpoint
-- Enable the pg_trgm extension for fuzzy text matching
CREATE EXTENSION IF NOT EXISTS pg_trgm;
---> statement-breakpoint
-- Create trigram GIN indexes for fast similarity searches
CREATE INDEX IF NOT EXISTS trgm_idx_title ON wiki_pages USING GIN (title gin_trgm_ops);
CREATE INDEX IF NOT EXISTS trgm_idx_content ON wiki_pages USING GIN (content gin_trgm_ops);
---> statement-breakpoint
-- Add comment to explain what these indexes are for
COMMENT ON INDEX trgm_idx_title IS 'Trigram index on wiki page titles for fuzzy search';
COMMENT ON INDEX trgm_idx_content IS 'Trigram index on wiki page content for fuzzy search';
---> statement-breakpoint
-- Display information about the created indexes
SELECT indexname,
indexdef
diff --git a/packages/db/drizzle/0001_colorful_orphan.sql b/packages/db/drizzle/0001_colorful_orphan.sql
new file mode 100644
index 0000000..bb6665d
--- /dev/null
+++ b/packages/db/drizzle/0001_colorful_orphan.sql
@@ -0,0 +1,23 @@
+CREATE TABLE "settings" (
+ "key" varchar(100) PRIMARY KEY NOT NULL,
+ "value" jsonb NOT NULL,
+ "description" text,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "settings_history" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "setting_key" varchar(100) NOT NULL,
+ "previous_value" jsonb,
+ "changed_by_id" integer,
+ "changed_at" timestamp DEFAULT now() NOT NULL,
+ "change_reason" text
+);
+--> statement-breakpoint
+ALTER TABLE "settings_history" ADD CONSTRAINT "settings_history_setting_key_settings_key_fk" FOREIGN KEY ("setting_key") REFERENCES "public"."settings"("key") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "settings_history" ADD CONSTRAINT "settings_history_changed_by_id_users_id_fk" FOREIGN KEY ("changed_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+CREATE UNIQUE INDEX "settings_key_idx" ON "settings" USING btree ("key");--> statement-breakpoint
+CREATE INDEX "settings_history_key_idx" ON "settings_history" USING btree ("setting_key");--> statement-breakpoint
+CREATE INDEX "settings_history_user_idx" ON "settings_history" USING btree ("changed_by_id");--> statement-breakpoint
+CREATE INDEX "settings_history_time_idx" ON "settings_history" USING btree ("changed_at");
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/0000_snapshot.json b/packages/db/drizzle/meta/0000_snapshot.json
index 22d892f..4f23fce 100644
--- a/packages/db/drizzle/meta/0000_snapshot.json
+++ b/packages/db/drizzle/meta/0000_snapshot.json
@@ -1,5 +1,5 @@
{
- "id": "beef51f4-c3ed-43a3-a992-7f4409b557d6",
+ "id": "3039bfc9-5805-4f39-b1de-8325ca9a2637",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -117,6 +117,52 @@
"checkConstraints": {},
"isRLSEnabled": false
},
+ "public.actions": {
+ "name": "actions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "actions_name_unique": {
+ "name": "actions_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
"public.assets": {
"name": "assets",
"schema": "",
@@ -292,9 +338,9 @@
"primaryKey": false,
"notNull": true
},
- "action": {
- "name": "action",
- "type": "varchar(50)",
+ "action_id": {
+ "name": "action_id",
+ "type": "integer",
"primaryKey": false,
"notNull": true
},
@@ -317,7 +363,7 @@
"nulls": "last"
},
{
- "expression": "action",
+ "expression": "action_id",
"isExpression": false,
"asc": true,
"nulls": "last"
@@ -342,14 +388,27 @@
],
"onDelete": "no action",
"onUpdate": "no action"
+ },
+ "group_action_permissions_action_id_actions_id_fk": {
+ "name": "group_action_permissions_action_id_actions_id_fk",
+ "tableFrom": "group_action_permissions",
+ "tableTo": "actions",
+ "columnsFrom": [
+ "action_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
}
},
"compositePrimaryKeys": {
- "group_action_permissions_group_id_action_pk": {
- "name": "group_action_permissions_group_id_action_pk",
+ "group_action_permissions_group_id_action_id_pk": {
+ "name": "group_action_permissions_group_id_action_id_pk",
"columns": [
"group_id",
- "action"
+ "action_id"
]
}
},
@@ -368,9 +427,9 @@
"primaryKey": false,
"notNull": true
},
- "module": {
- "name": "module",
- "type": "varchar(50)",
+ "module_id": {
+ "name": "module_id",
+ "type": "integer",
"primaryKey": false,
"notNull": true
},
@@ -393,7 +452,7 @@
"nulls": "last"
},
{
- "expression": "module",
+ "expression": "module_id",
"isExpression": false,
"asc": true,
"nulls": "last"
@@ -418,14 +477,27 @@
],
"onDelete": "no action",
"onUpdate": "no action"
+ },
+ "group_module_permissions_module_id_modules_id_fk": {
+ "name": "group_module_permissions_module_id_modules_id_fk",
+ "tableFrom": "group_module_permissions",
+ "tableTo": "modules",
+ "columnsFrom": [
+ "module_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
}
},
"compositePrimaryKeys": {
- "group_module_permissions_group_id_module_pk": {
- "name": "group_module_permissions_group_id_module_pk",
+ "group_module_permissions_group_id_module_id_pk": {
+ "name": "group_module_permissions_group_id_module_id_pk",
"columns": [
"group_id",
- "module"
+ "module_id"
]
}
},
@@ -597,6 +669,52 @@
"checkConstraints": {},
"isRLSEnabled": false
},
+ "public.modules": {
+ "name": "modules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "modules_name_unique": {
+ "name": "modules_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
"public.page_permissions": {
"name": "page_permissions",
"schema": "",
@@ -726,9 +844,9 @@
"primaryKey": true,
"notNull": true
},
- "module": {
- "name": "module",
- "type": "varchar(50)",
+ "module_id": {
+ "name": "module_id",
+ "type": "integer",
"primaryKey": false,
"notNull": true
},
@@ -738,22 +856,12 @@
"primaryKey": false,
"notNull": true
},
- "action": {
- "name": "action",
- "type": "varchar(50)",
+ "action_id": {
+ "name": "action_id",
+ "type": "integer",
"primaryKey": false,
"notNull": true
},
- "name": {
- "name": "name",
- "type": "varchar(100)",
- "primaryKey": false,
- "notNull": true,
- "generated": {
- "as": "\"module\" || ':' || \"resource\" || ':' || \"action\"",
- "type": "stored"
- }
- },
"description": {
"name": "description",
"type": "text",
@@ -768,18 +876,65 @@
"default": "now()"
}
},
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "permissions_name_unique": {
- "name": "permissions_name_unique",
- "nullsNotDistinct": false,
+ "indexes": {
+ "permission_uniq_idx": {
+ "name": "permission_uniq_idx",
"columns": [
- "name"
- ]
+ {
+ "expression": "module_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "action_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "permissions_module_id_modules_id_fk": {
+ "name": "permissions_module_id_modules_id_fk",
+ "tableFrom": "permissions",
+ "tableTo": "modules",
+ "columnsFrom": [
+ "module_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "permissions_action_id_actions_id_fk": {
+ "name": "permissions_action_id_actions_id_fk",
+ "tableFrom": "permissions",
+ "tableTo": "actions",
+ "columnsFrom": [
+ "action_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
}
},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
@@ -1042,7 +1197,29 @@
"notNull": true
}
},
- "indexes": {},
+ "indexes": {
+ "verification_token_idx": {
+ "name": "verification_token_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
"foreignKeys": {},
"compositePrimaryKeys": {
"verification_tokens_identifier_token_pk": {
diff --git a/packages/db/drizzle/meta/0001_snapshot.json b/packages/db/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000..39b86dc
--- /dev/null
+++ b/packages/db/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,1816 @@
+{
+ "id": "020c9cda-3bbe-4e72-a57a-1b2fdb432d9f",
+ "prevId": "3039bfc9-5805-4f39-b1de-8325ca9a2637",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.accounts": {
+ "name": "accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "accounts_user_id_users_id_fk": {
+ "name": "accounts_user_id_users_id_fk",
+ "tableFrom": "accounts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.actions": {
+ "name": "actions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "actions_name_unique": {
+ "name": "actions_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.assets": {
+ "name": "assets",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "file_name": {
+ "name": "file_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_type": {
+ "name": "file_type",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_size": {
+ "name": "file_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uploaded_by_id": {
+ "name": "uploaded_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "assets_uploaded_by_id_users_id_fk": {
+ "name": "assets_uploaded_by_id_users_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "users",
+ "columnsFrom": [
+ "uploaded_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.assets_to_pages": {
+ "name": "assets_to_pages",
+ "schema": "",
+ "columns": {
+ "asset_id": {
+ "name": "asset_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "asset_page_idx": {
+ "name": "asset_page_idx",
+ "columns": [
+ {
+ "expression": "asset_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "assets_to_pages_asset_id_assets_id_fk": {
+ "name": "assets_to_pages_asset_id_assets_id_fk",
+ "tableFrom": "assets_to_pages",
+ "tableTo": "assets",
+ "columnsFrom": [
+ "asset_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "assets_to_pages_page_id_wiki_pages_id_fk": {
+ "name": "assets_to_pages_page_id_wiki_pages_id_fk",
+ "tableFrom": "assets_to_pages",
+ "tableTo": "wiki_pages",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "assets_to_pages_asset_id_page_id_pk": {
+ "name": "assets_to_pages_asset_id_page_id_pk",
+ "columns": [
+ "asset_id",
+ "page_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.group_action_permissions": {
+ "name": "group_action_permissions",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action_id": {
+ "name": "action_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "group_action_permissions_idx": {
+ "name": "group_action_permissions_idx",
+ "columns": [
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "action_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "group_action_permissions_group_id_groups_id_fk": {
+ "name": "group_action_permissions_group_id_groups_id_fk",
+ "tableFrom": "group_action_permissions",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "group_action_permissions_action_id_actions_id_fk": {
+ "name": "group_action_permissions_action_id_actions_id_fk",
+ "tableFrom": "group_action_permissions",
+ "tableTo": "actions",
+ "columnsFrom": [
+ "action_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "group_action_permissions_group_id_action_id_pk": {
+ "name": "group_action_permissions_group_id_action_id_pk",
+ "columns": [
+ "group_id",
+ "action_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.group_module_permissions": {
+ "name": "group_module_permissions",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "module_id": {
+ "name": "module_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "group_module_permissions_idx": {
+ "name": "group_module_permissions_idx",
+ "columns": [
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "module_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "group_module_permissions_group_id_groups_id_fk": {
+ "name": "group_module_permissions_group_id_groups_id_fk",
+ "tableFrom": "group_module_permissions",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "group_module_permissions_module_id_modules_id_fk": {
+ "name": "group_module_permissions_module_id_modules_id_fk",
+ "tableFrom": "group_module_permissions",
+ "tableTo": "modules",
+ "columnsFrom": [
+ "module_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "group_module_permissions_group_id_module_id_pk": {
+ "name": "group_module_permissions_group_id_module_id_pk",
+ "columns": [
+ "group_id",
+ "module_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.group_permissions": {
+ "name": "group_permissions",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_id": {
+ "name": "permission_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "group_permission_idx": {
+ "name": "group_permission_idx",
+ "columns": [
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "permission_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "group_permissions_group_id_groups_id_fk": {
+ "name": "group_permissions_group_id_groups_id_fk",
+ "tableFrom": "group_permissions",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "group_permissions_permission_id_permissions_id_fk": {
+ "name": "group_permissions_permission_id_permissions_id_fk",
+ "tableFrom": "group_permissions",
+ "tableTo": "permissions",
+ "columnsFrom": [
+ "permission_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "group_permissions_group_id_permission_id_pk": {
+ "name": "group_permissions_group_id_permission_id_pk",
+ "columns": [
+ "group_id",
+ "permission_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.groups": {
+ "name": "groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "is_system": {
+ "name": "is_system",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "is_editable": {
+ "name": "is_editable",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": true
+ },
+ "allow_user_assignment": {
+ "name": "allow_user_assignment",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "groups_name_unique": {
+ "name": "groups_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.modules": {
+ "name": "modules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "modules_name_unique": {
+ "name": "modules_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.page_permissions": {
+ "name": "page_permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "permission_id": {
+ "name": "permission_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_type": {
+ "name": "permission_type",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'allow'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "page_group_perm_idx": {
+ "name": "page_group_perm_idx",
+ "columns": [
+ {
+ "expression": "page_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "permission_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "page_permissions_page_id_wiki_pages_id_fk": {
+ "name": "page_permissions_page_id_wiki_pages_id_fk",
+ "tableFrom": "page_permissions",
+ "tableTo": "wiki_pages",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "page_permissions_group_id_groups_id_fk": {
+ "name": "page_permissions_group_id_groups_id_fk",
+ "tableFrom": "page_permissions",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "page_permissions_permission_id_permissions_id_fk": {
+ "name": "page_permissions_permission_id_permissions_id_fk",
+ "tableFrom": "page_permissions",
+ "tableTo": "permissions",
+ "columnsFrom": [
+ "permission_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.permissions": {
+ "name": "permissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "module_id": {
+ "name": "module_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource": {
+ "name": "resource",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action_id": {
+ "name": "action_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "permission_uniq_idx": {
+ "name": "permission_uniq_idx",
+ "columns": [
+ {
+ "expression": "module_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "action_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "permissions_module_id_modules_id_fk": {
+ "name": "permissions_module_id_modules_id_fk",
+ "tableFrom": "permissions",
+ "tableTo": "modules",
+ "columnsFrom": [
+ "module_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "permissions_action_id_actions_id_fk": {
+ "name": "permissions_action_id_actions_id_fk",
+ "tableFrom": "permissions",
+ "tableTo": "actions",
+ "columnsFrom": [
+ "action_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "session_token": {
+ "name": "session_token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_session_token_unique": {
+ "name": "sessions_session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "session_token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.settings": {
+ "name": "settings",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "varchar(100)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "settings_key_idx": {
+ "name": "settings_key_idx",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.settings_history": {
+ "name": "settings_history",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "setting_key": {
+ "name": "setting_key",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "previous_value": {
+ "name": "previous_value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "changed_by_id": {
+ "name": "changed_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "changed_at": {
+ "name": "changed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "change_reason": {
+ "name": "change_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "settings_history_key_idx": {
+ "name": "settings_history_key_idx",
+ "columns": [
+ {
+ "expression": "setting_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "settings_history_user_idx": {
+ "name": "settings_history_user_idx",
+ "columns": [
+ {
+ "expression": "changed_by_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "settings_history_time_idx": {
+ "name": "settings_history_time_idx",
+ "columns": [
+ {
+ "expression": "changed_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "settings_history_setting_key_settings_key_fk": {
+ "name": "settings_history_setting_key_settings_key_fk",
+ "tableFrom": "settings_history",
+ "tableTo": "settings",
+ "columnsFrom": [
+ "setting_key"
+ ],
+ "columnsTo": [
+ "key"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "settings_history_changed_by_id_users_id_fk": {
+ "name": "settings_history_changed_by_id_users_id_fk",
+ "tableFrom": "settings_history",
+ "tableTo": "users",
+ "columnsFrom": [
+ "changed_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_groups": {
+ "name": "user_groups",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "user_group_idx": {
+ "name": "user_group_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_groups_user_id_users_id_fk": {
+ "name": "user_groups_user_id_users_id_fk",
+ "tableFrom": "user_groups",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "user_groups_group_id_groups_id_fk": {
+ "name": "user_groups_group_id_groups_id_fk",
+ "tableFrom": "user_groups",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_groups_user_id_group_id_pk": {
+ "name": "user_groups_user_id_group_id_pk",
+ "columns": [
+ "user_id",
+ "group_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password": {
+ "name": "password",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "email_idx": {
+ "name": "email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification_tokens": {
+ "name": "verification_tokens",
+ "schema": "",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "verification_token_idx": {
+ "name": "verification_token_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verification_tokens_identifier_token_pk": {
+ "name": "verification_tokens_identifier_token_pk",
+ "columns": [
+ "identifier",
+ "token"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.wiki_page_revisions": {
+ "name": "wiki_page_revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "page_id": {
+ "name": "page_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "wiki_page_revisions_page_id_wiki_pages_id_fk": {
+ "name": "wiki_page_revisions_page_id_wiki_pages_id_fk",
+ "tableFrom": "wiki_page_revisions",
+ "tableTo": "wiki_pages",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "wiki_page_revisions_created_by_id_users_id_fk": {
+ "name": "wiki_page_revisions_created_by_id_users_id_fk",
+ "tableFrom": "wiki_page_revisions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.wiki_page_to_tag": {
+ "name": "wiki_page_to_tag",
+ "schema": "",
+ "columns": {
+ "page_id": {
+ "name": "page_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "wiki_page_to_tag_page_id_wiki_pages_id_fk": {
+ "name": "wiki_page_to_tag_page_id_wiki_pages_id_fk",
+ "tableFrom": "wiki_page_to_tag",
+ "tableTo": "wiki_pages",
+ "columnsFrom": [
+ "page_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "wiki_page_to_tag_tag_id_wiki_tags_id_fk": {
+ "name": "wiki_page_to_tag_tag_id_wiki_tags_id_fk",
+ "tableFrom": "wiki_page_to_tag",
+ "tableTo": "wiki_tags",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "wiki_page_to_tag_page_id_tag_id_pk": {
+ "name": "wiki_page_to_tag_page_id_tag_id_pk",
+ "columns": [
+ "page_id",
+ "tag_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.wiki_pages": {
+ "name": "wiki_pages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "path": {
+ "name": "path",
+ "type": "varchar(1000)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rendered_html": {
+ "name": "rendered_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "editor_type": {
+ "name": "editor_type",
+ "type": "editor_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_by_id": {
+ "name": "updated_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "rendered_html_updated_at": {
+ "name": "rendered_html_updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "locked_by_id": {
+ "name": "locked_by_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "locked_at": {
+ "name": "locked_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lock_expires_at": {
+ "name": "lock_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "search": {
+ "name": "search",
+ "type": "tsvector",
+ "primaryKey": false,
+ "notNull": true,
+ "generated": {
+ "as": "setweight(to_tsvector('english', \"wiki_pages\".\"title\"), 'A')\n ||\n setweight(to_tsvector('english', \"wiki_pages\".\"content\"), 'B')",
+ "type": "stored"
+ }
+ }
+ },
+ "indexes": {
+ "idx_search": {
+ "name": "idx_search",
+ "columns": [
+ {
+ "expression": "search",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "trgm_idx_title": {
+ "name": "trgm_idx_title",
+ "columns": [
+ {
+ "expression": "title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "wiki_pages_created_by_id_users_id_fk": {
+ "name": "wiki_pages_created_by_id_users_id_fk",
+ "tableFrom": "wiki_pages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "wiki_pages_updated_by_id_users_id_fk": {
+ "name": "wiki_pages_updated_by_id_users_id_fk",
+ "tableFrom": "wiki_pages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "updated_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "wiki_pages_locked_by_id_users_id_fk": {
+ "name": "wiki_pages_locked_by_id_users_id_fk",
+ "tableFrom": "wiki_pages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "locked_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "wiki_pages_path_unique": {
+ "name": "wiki_pages_path_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "path"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.wiki_tags": {
+ "name": "wiki_tags",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "wiki_tags_name_unique": {
+ "name": "wiki_tags_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.editor_type": {
+ "name": "editor_type",
+ "schema": "public",
+ "values": [
+ "markdown",
+ "html"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 8f50c52..f02a08a 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -5,8 +5,15 @@
{
"idx": 0,
"version": "7",
- "when": 1745364296469,
- "tag": "0000_light_sumo",
+ "when": 1745433510377,
+ "tag": "0000_silly_iceman",
+ "breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1745447221512,
+ "tag": "0001_colorful_orphan",
"breakpoints": true
}
]
diff --git a/packages/db/package.json b/packages/db/package.json
index e1ddd50..23a3dc7 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -35,6 +35,7 @@
"_db:seed:custom": "tsx src/seeds/custom-seeds.ts"
},
"dependencies": {
+ "@repo/types": "workspace:*",
"@neondatabase/serverless": "^1.0.0",
"@repo/logger": "workspace:*",
"bcryptjs": "^3.0.2",
diff --git a/packages/db/src/registry/permissions.ts b/packages/db/src/registry/permissions.ts
index 5d91a8d..ce8233e 100644
--- a/packages/db/src/registry/permissions.ts
+++ b/packages/db/src/registry/permissions.ts
@@ -42,6 +42,26 @@ const PERMISSION_LIST: Permission[] = [
description: "Move or rename wiki pages",
},
+ // Admin module permissions
+ {
+ module: "admin",
+ resource: "dashboard",
+ action: "read",
+ description: "Access the admin dashboard",
+ },
+ {
+ module: "admin",
+ resource: "wiki",
+ action: "read",
+ description: "Read wiki pages in the admin panel (list view)",
+ },
+ {
+ module: "admin",
+ resource: "wiki",
+ action: "delete",
+ description: "Delete wiki pages from the admin panel",
+ },
+
// System module permissions
{
module: "system",
diff --git a/packages/db/src/registry/types.ts b/packages/db/src/registry/types.ts
index 03a54a7..b34bae3 100644
--- a/packages/db/src/registry/types.ts
+++ b/packages/db/src/registry/types.ts
@@ -5,7 +5,7 @@
/**
* Valid permission modules in the system
*/
-export type PermissionModule = "wiki" | "system" | "assets";
+export type PermissionModule = "wiki" | "system" | "assets" | "admin";
/**
* Valid permission actions in the system
@@ -22,7 +22,9 @@ export type PermissionResource =
| "users"
| "groups"
| "asset"
- | "general";
+ | "general"
+ | "dashboard"
+ | "wiki";
/**
* Unified permission structure
diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts
index ce789dd..ac469e8 100644
--- a/packages/db/src/schema/index.ts
+++ b/packages/db/src/schema/index.ts
@@ -11,8 +11,11 @@ import {
index,
pgEnum,
uuid,
+ uniqueIndex,
+ jsonb,
} from "drizzle-orm/pg-core";
import { relations, SQL, sql } from "drizzle-orm";
+import { SettingKey } from "@repo/types";
// Define custom PostgreSQL extension for trigrams
export const pgExtensions = sql`
@@ -43,22 +46,43 @@ export const users = pgTable(
(t) => [index("email_idx").on(t.email)]
);
-// Permissions table - defines available permissions in the system
-export const permissions = pgTable("permissions", {
+// Modules table
+export const modules = pgTable("modules", {
id: serial("id").primaryKey(),
- module: varchar("module", { length: 50 }).notNull(), // e.g., 'wiki', 'system', 'assets'
- resource: varchar("resource", { length: 50 }).notNull(), // e.g., 'page', 'asset', 'user'
- action: varchar("action", { length: 50 }).notNull(), // e.g., 'create', 'read', 'update', 'delete'
- name: varchar("name", { length: 100 })
- .notNull()
- .generatedAlwaysAs(
- (): SQL => sql`"module" || ':' || "resource" || ':' || "action"`
- )
- .unique(),
+ name: varchar("name", { length: 50 }).notNull().unique(),
description: text("description"),
createdAt: timestamp("created_at").defaultNow(),
});
+// Actions table
+export const actions = pgTable("actions", {
+ id: serial("id").primaryKey(),
+ name: varchar("name", { length: 50 }).notNull().unique(),
+ description: text("description"),
+ createdAt: timestamp("created_at").defaultNow(),
+});
+
+// Permissions table - defines available permissions in the system
+export const permissions = pgTable(
+ "permissions",
+ {
+ id: serial("id").primaryKey(),
+ moduleId: integer("module_id")
+ .notNull()
+ .references(() => modules.id),
+ resource: varchar("resource", { length: 50 }).notNull(),
+ actionId: integer("action_id")
+ .notNull()
+ .references(() => actions.id),
+ description: text("description"),
+ createdAt: timestamp("created_at").defaultNow(),
+ },
+ (t) => [
+ // Ensure logical uniqueness for a permission
+ uniqueIndex("permission_uniq_idx").on(t.moduleId, t.resource, t.actionId),
+ ]
+);
+
// Groups table - custom user groups
export const groups = pgTable("groups", {
id: serial("id").primaryKey(),
@@ -114,12 +138,14 @@ export const groupModulePermissions = pgTable(
groupId: integer("group_id")
.references(() => groups.id)
.notNull(),
- module: varchar("module", { length: 50 }).notNull(),
+ moduleId: integer("module_id") // Changed from varchar("module")
+ .references(() => modules.id)
+ .notNull(),
createdAt: timestamp("created_at").defaultNow(),
},
(t) => [
- primaryKey({ columns: [t.groupId, t.module] }),
- index("group_module_permissions_idx").on(t.groupId, t.module),
+ primaryKey({ columns: [t.groupId, t.moduleId] }), // Updated primary key column
+ index("group_module_permissions_idx").on(t.groupId, t.moduleId), // Updated index column
]
);
@@ -130,12 +156,14 @@ export const groupActionPermissions = pgTable(
groupId: integer("group_id")
.references(() => groups.id)
.notNull(),
- action: varchar("action", { length: 50 }).notNull(),
+ actionId: integer("action_id") // Changed from varchar("action")
+ .references(() => actions.id)
+ .notNull(),
createdAt: timestamp("created_at").defaultNow(),
},
(t) => [
- primaryKey({ columns: [t.groupId, t.action] }),
- index("group_action_permissions_idx").on(t.groupId, t.action),
+ primaryKey({ columns: [t.groupId, t.actionId] }), // Updated primary key column
+ index("group_action_permissions_idx").on(t.groupId, t.actionId), // Updated index column
]
);
@@ -185,7 +213,15 @@ export const groupsRelations = relations(groups, ({ many }) => ({
}));
// Permission relations
-export const permissionsRelations = relations(permissions, ({ many }) => ({
+export const permissionsRelations = relations(permissions, ({ one, many }) => ({
+ module: one(modules, {
+ fields: [permissions.moduleId],
+ references: [modules.id],
+ }),
+ action: one(actions, {
+ fields: [permissions.actionId],
+ references: [actions.id],
+ }),
groupPermissions: many(groupPermissions),
pagePermissions: many(pagePermissions),
}));
@@ -243,6 +279,11 @@ export const groupModulePermissionsRelations = relations(
fields: [groupModulePermissions.groupId],
references: [groups.id],
}),
+ module: one(modules, {
+ // Added relation to modules
+ fields: [groupModulePermissions.moduleId],
+ references: [modules.id],
+ }),
})
);
@@ -253,9 +294,26 @@ export const groupActionPermissionsRelations = relations(
fields: [groupActionPermissions.groupId],
references: [groups.id],
}),
+ action: one(actions, {
+ // Added relation to actions
+ fields: [groupActionPermissions.actionId],
+ references: [actions.id],
+ }),
})
);
+// Module relations
+export const modulesRelations = relations(modules, ({ many }) => ({
+ permissions: many(permissions),
+ groupModulePermissions: many(groupModulePermissions),
+}));
+
+// Action relations
+export const actionsRelations = relations(actions, ({ many }) => ({
+ permissions: many(permissions),
+ groupActionPermissions: many(groupActionPermissions),
+}));
+
export const wikiPageEditorTypeEnum = pgEnum("editor_type", [
"markdown",
"html",
@@ -442,9 +500,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
@@ -506,3 +565,56 @@ export const assetsToPagesRelations = relations(assetsToPages, ({ one }) => ({
references: [wikiPages.id],
}),
}));
+
+// Settings table - stores application settings
+export const settings = pgTable(
+ "settings",
+ {
+ key: varchar("key", { length: 100 }).primaryKey().$type(),
+ value: jsonb("value").notNull(), // Store setting value as JSONB
+ description: text("description"),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (t) => [uniqueIndex("settings_key_idx").on(t.key)]
+);
+
+// Settings history table - tracks changes to settings
+export const settingsHistory = pgTable(
+ "settings_history",
+ {
+ id: serial("id").primaryKey(),
+ settingKey: varchar("setting_key", { length: 100 })
+ .notNull()
+ .$type()
+ .references(() => settings.key),
+ previousValue: jsonb("previous_value"), // Previous value as JSONB
+ changedById: integer("changed_by_id").references(() => users.id),
+ changedAt: timestamp("changed_at").defaultNow().notNull(),
+ changeReason: text("change_reason"),
+ },
+ (t) => [
+ index("settings_history_key_idx").on(t.settingKey),
+ index("settings_history_user_idx").on(t.changedById),
+ index("settings_history_time_idx").on(t.changedAt),
+ ]
+);
+
+// Add relations for settings
+export const settingsRelations = relations(settings, ({ many }) => ({
+ history: many(settingsHistory),
+}));
+
+export const settingsHistoryRelations = relations(
+ settingsHistory,
+ ({ one }) => ({
+ setting: one(settings, {
+ fields: [settingsHistory.settingKey],
+ references: [settings.key],
+ }),
+ changedBy: one(users, {
+ fields: [settingsHistory.changedById],
+ references: [users.id],
+ }),
+ })
+);
diff --git a/packages/db/src/seeds/permissions.ts b/packages/db/src/seeds/permissions.ts
index 3a8034d..aa4cf73 100644
--- a/packages/db/src/seeds/permissions.ts
+++ b/packages/db/src/seeds/permissions.ts
@@ -1,12 +1,60 @@
import { db } from "../index.js";
import * as schema from "../schema/index.js";
+import { getAllPermissions } from "../registry/permissions.js";
import {
- getAllPermissions,
- createPermissionId,
-} from "../registry/permissions.js";
-import { Permission } from "../registry/types.js";
+ Permission,
+ PermissionModule,
+ PermissionAction,
+ PermissionResource,
+} from "../registry/types.js";
import { sql } from "drizzle-orm";
+/**
+ * Seed Modules and Actions from the registry
+ */
+async function seedModulesAndActions(registryPermissions: Permission[]) {
+ console.log(`Seeding modules and actions...`);
+
+ const uniqueModules = [
+ ...new Map(
+ registryPermissions.map((p) => [p.module, { name: p.module }])
+ ).values(),
+ ];
+ const uniqueActions = [
+ ...new Map(
+ registryPermissions.map((p) => [p.action, { name: p.action }])
+ ).values(),
+ ];
+
+ const modulePromise = db
+ .insert(schema.modules)
+ .values(uniqueModules)
+ .onConflictDoNothing()
+ .returning();
+
+ const actionPromise = db
+ .insert(schema.actions)
+ .values(uniqueActions)
+ .onConflictDoNothing()
+ .returning();
+
+ await Promise.all([modulePromise, actionPromise]);
+
+ // Fetch all modules and actions (including existing ones) to get IDs
+ const [allModules, allActions] = await Promise.all([
+ db.query.modules.findMany(),
+ db.query.actions.findMany(),
+ ]);
+
+ const moduleNameToIdMap = new Map(allModules.map((m) => [m.name, m.id]));
+ const actionNameToIdMap = new Map(allActions.map((a) => [a.name, a.id]));
+
+ console.log(
+ `Modules and actions seeding finished. Found ${allModules.length} modules, ${allActions.length} actions.`
+ );
+ return { moduleNameToIdMap, actionNameToIdMap };
+}
+
/**
* Seed all permissions from the central registry
*/
@@ -14,36 +62,62 @@ export async function seedPermissions() {
console.log(`Seeding permissions from registry...`);
const registryPermissions = getAllPermissions();
+ if (registryPermissions.length === 0) {
+ console.warn("No permissions found in the registry to seed.");
+ return;
+ }
+
+ const { moduleNameToIdMap, actionNameToIdMap } =
+ await seedModulesAndActions(registryPermissions);
- // Prepare permissions for insertion, generating the unique name
- const permissionsToInsert = registryPermissions.map((p: Permission) => ({
- module: p.module,
- resource: p.resource,
- action: p.action,
- description: p.description,
- name: createPermissionId(p), // Use the function to generate the name
- }));
+ // Fetch existing permissions to avoid duplicates
+ const existingPermissions = await db.query.permissions.findMany({
+ columns: {
+ moduleId: true,
+ resource: true,
+ actionId: true,
+ },
+ });
+ const existingSet = new Set(
+ existingPermissions.map((p) => `${p.moduleId}:${p.resource}:${p.actionId}`)
+ );
- console.log(`Found ${permissionsToInsert.length} permissions to seed.`);
+ // Prepare permissions for insertion, filtering out existing ones
+ const permissionsToInsert = registryPermissions
+ .map((p: Permission) => {
+ const moduleId = moduleNameToIdMap.get(p.module);
+ const actionId = actionNameToIdMap.get(p.action);
+ if (moduleId === undefined || actionId === undefined) {
+ console.warn(
+ `Skipping permission: Could not find ID for module '${p.module}' or action '${p.action}'.`
+ );
+ return null;
+ }
+ return {
+ moduleId: moduleId,
+ resource: p.resource,
+ actionId: actionId,
+ description: p.description,
+ };
+ })
+ .filter((p): p is NonNullable => p !== null) // Type guard to remove nulls
+ .filter(
+ (p) => !existingSet.has(`${p.moduleId}:${p.resource}:${p.actionId}`)
+ );
+
+ console.log(`Found ${permissionsToInsert.length} new permissions to seed.`);
if (permissionsToInsert.length === 0) {
- console.warn("No permissions found in the registry to seed.");
+ console.log("No new permissions to insert.");
+ console.log("Permissions seeding finished.");
return;
}
try {
- // Use insert with onConflictDoUpdate to handle existing permissions
- await db
- .insert(schema.permissions)
- .values(permissionsToInsert)
- .onConflictDoUpdate({
- target: schema.permissions.name, // Target the unique name column
- set: {
- // Use sql`excluded.column_name` syntax
- description: sql`excluded.description`,
- },
- });
+ // Insert only the new permissions
+ await db.insert(schema.permissions).values(permissionsToInsert);
+ console.log(`${permissionsToInsert.length} new permissions inserted.`);
console.log("Permissions seeding finished.");
} catch (error) {
console.error(`Error during permissions seeding:`, error);
@@ -90,13 +164,14 @@ export async function createDefaultGroups() {
},
];
+ // Use Promise.all for potentially parallelizable inserts if DB supports it well,
+ // but sequential insertReturning is safer for getting IDs back reliably.
const createdGroups = await db
.insert(schema.groups)
.values(groupsToCreate)
.onConflictDoUpdate({
target: schema.groups.name,
set: {
- // Use sql`excluded.column_name` syntax
description: sql`excluded.description`,
isEditable: sql`excluded.is_editable`,
allowUserAssignment: sql`excluded.allow_user_assignment`,
@@ -110,8 +185,13 @@ export async function createDefaultGroups() {
const viewerGroup = createdGroups.find((g) => g.name === "Viewers");
const guestGroup = createdGroups.find((g) => g.name === "Guests");
- // --- Assign Permissions ---
- const allDbPermissions = await db.query.permissions.findMany();
+ // --- Fetch Data Needed for Assignments ---
+ const [allDbPermissions, allModules, allActions] = await Promise.all([
+ db.query.permissions.findMany(),
+ db.query.modules.findMany(),
+ db.query.actions.findMany(),
+ ]);
+
if (!allDbPermissions || allDbPermissions.length === 0) {
console.error(
"No permissions found in the database. Cannot assign permissions to groups. Ensure seedPermissions ran successfully."
@@ -119,24 +199,49 @@ export async function createDefaultGroups() {
return;
}
- const findPermission = (id: string) =>
- allDbPermissions.find((p) => p.name === id);
+ const moduleNameToIdMap = new Map(allModules.map((m) => [m.name, m.id]));
+ const actionNameToIdMap = new Map(allActions.map((a) => [a.name, a.id]));
+
+ // Helper to find a permission ID based on its logical components
+ const findPermissionId = (
+ moduleName: PermissionModule,
+ resource: PermissionResource,
+ actionName: PermissionAction
+ ): number | undefined => {
+ const moduleId = moduleNameToIdMap.get(moduleName);
+ const actionId = actionNameToIdMap.get(actionName);
+ if (moduleId === undefined || actionId === undefined) return undefined;
+ const permission = allDbPermissions.find(
+ (p) =>
+ p.moduleId === moduleId &&
+ p.resource === resource &&
+ p.actionId === actionId
+ );
+ return permission?.id;
+ };
+
+ // --- Assign Permissions --- //
+ const assignmentPromises: Promise[] = [];
// Administrator: All permissions
if (adminGroup) {
const allPermissionIds = allDbPermissions.map((p) => p.id);
if (allPermissionIds.length > 0) {
- await db
- .insert(schema.groupPermissions)
- .values(
- allPermissionIds.map((permissionId) => ({
- groupId: adminGroup.id,
- permissionId,
- }))
- )
- .onConflictDoNothing();
- console.log(
- `Assigned ${allPermissionIds.length} permissions to Administrators.`
+ assignmentPromises.push(
+ db
+ .insert(schema.groupPermissions)
+ .values(
+ allPermissionIds.map((permissionId) => ({
+ groupId: adminGroup.id,
+ permissionId,
+ }))
+ )
+ .onConflictDoNothing()
+ .then(() =>
+ console.log(
+ `Assigned ${allPermissionIds.length} permissions to Administrators.`
+ )
+ )
);
} else {
console.warn("No permissions available to assign to Administrators.");
@@ -145,24 +250,30 @@ export async function createDefaultGroups() {
// Editors: Wiki create, read, update + Asset read
if (editorGroup) {
- const editorPerms = [
- findPermission("wiki:page:create"),
- findPermission("wiki:page:read"),
- findPermission("wiki:page:update"),
- findPermission("assets:asset:read"), // Editors likely need to see assets too
- ].filter((p) => p !== undefined) as typeof allDbPermissions;
-
- if (editorPerms.length > 0) {
- await db
- .insert(schema.groupPermissions)
- .values(
- editorPerms.map((p) => ({
- groupId: editorGroup.id,
- permissionId: p.id,
- }))
- )
- .onConflictDoNothing();
- console.log(`Assigned ${editorPerms.length} permissions to Editors.`);
+ const editorPermIds = [
+ findPermissionId("wiki", "page", "create"),
+ findPermissionId("wiki", "page", "read"),
+ findPermissionId("wiki", "page", "update"),
+ findPermissionId("assets", "asset", "read"), // Editors likely need to see assets too
+ ].filter((id): id is number => id !== undefined);
+
+ if (editorPermIds.length > 0) {
+ assignmentPromises.push(
+ db
+ .insert(schema.groupPermissions)
+ .values(
+ editorPermIds.map((permissionId) => ({
+ groupId: editorGroup.id,
+ permissionId,
+ }))
+ )
+ .onConflictDoNothing()
+ .then(() =>
+ console.log(
+ `Assigned ${editorPermIds.length} permissions to Editors.`
+ )
+ )
+ );
} else {
console.warn("Could not find necessary permissions for Editors.");
}
@@ -170,27 +281,31 @@ export async function createDefaultGroups() {
// Viewers & Guests: Wiki read, Asset read
const readPermIds = [
- findPermission("wiki:page:read")?.id,
- findPermission("assets:asset:read")?.id,
- ].filter((id) => id !== undefined) as number[];
+ findPermissionId("wiki", "page", "read"),
+ findPermissionId("assets", "asset", "read"),
+ ].filter((id): id is number => id !== undefined);
const viewerGuestGroups = [viewerGroup, guestGroup].filter(
- (g) => g !== undefined
- ) as typeof createdGroups;
+ (g): g is NonNullable => g !== undefined
+ );
if (readPermIds.length > 0) {
for (const group of viewerGuestGroups) {
- await db
- .insert(schema.groupPermissions)
- .values(
- readPermIds.map((permissionId) => ({
- groupId: group.id,
- permissionId,
- }))
- )
- .onConflictDoNothing();
- console.log(
- `Assigned ${readPermIds.length} read permissions to ${group.name}.`
+ assignmentPromises.push(
+ db
+ .insert(schema.groupPermissions)
+ .values(
+ readPermIds.map((permissionId) => ({
+ groupId: group.id,
+ permissionId,
+ }))
+ )
+ .onConflictDoNothing()
+ .then(() =>
+ console.log(
+ `Assigned ${readPermIds.length} read permissions to ${group.name}.`
+ )
+ )
);
}
} else {
@@ -198,23 +313,43 @@ export async function createDefaultGroups() {
}
// -- Assign Module/Action Permissions (Simplified Example) --
- // You might need more granular control here based on your specific needs
- // Example: Assign 'wiki' and 'assets' module access and 'read' action to Viewers/Guests
- for (const group of viewerGuestGroups) {
- await db
- .insert(schema.groupModulePermissions)
- .values([
- { groupId: group.id, module: "wiki" },
- { groupId: group.id, module: "assets" },
- ])
- .onConflictDoNothing();
- await db
- .insert(schema.groupActionPermissions)
- .values({ groupId: group.id, action: "read" })
- .onConflictDoNothing();
- console.log(`Assigned basic module/action permissions to ${group.name}.`);
+ const wikiModuleId = moduleNameToIdMap.get("wiki");
+ const assetsModuleId = moduleNameToIdMap.get("assets");
+ const readActionId = actionNameToIdMap.get("read");
+
+ if (wikiModuleId && assetsModuleId && readActionId) {
+ for (const group of viewerGuestGroups) {
+ assignmentPromises.push(
+ db
+ .insert(schema.groupModulePermissions)
+ .values([
+ { groupId: group.id, moduleId: wikiModuleId },
+ { groupId: group.id, moduleId: assetsModuleId },
+ ])
+ .onConflictDoNothing()
+ .then(() =>
+ console.log(`Assigned module permissions to ${group.name}.`)
+ )
+ );
+ assignmentPromises.push(
+ db
+ .insert(schema.groupActionPermissions)
+ .values({ groupId: group.id, actionId: readActionId })
+ .onConflictDoNothing()
+ .then(() =>
+ console.log(`Assigned action permissions to ${group.name}.`)
+ )
+ );
+ }
+ } else {
+ console.warn(
+ "Could not find IDs for basic module/action permissions (wiki, assets, read)."
+ );
}
+ // Wait for all assignments to complete
+ await Promise.all(assignmentPromises);
+
console.log("Default groups and permissions assigned successfully!");
} catch (error) {
console.error(
diff --git a/packages/db/src/seeds/run.ts b/packages/db/src/seeds/run.ts
index bf0c416..de27f0a 100644
--- a/packages/db/src/seeds/run.ts
+++ b/packages/db/src/seeds/run.ts
@@ -1,5 +1,6 @@
import { createDefaultGroups, seedPermissions } from "./permissions.js";
import { runDeveloperSeeds } from "./developer-seeds.js";
+import { seedSettings } from "./settings.js";
/**
* Main function to run all seed operations.
@@ -14,11 +15,14 @@ async function seed() {
// 2. Create Default Groups and assign base permissions
await createDefaultGroups();
+ // 3. Seed Default Settings
+ await seedSettings();
+
if (!process.env.SKIP_DEVELOPER_SEEDS) {
- // 3. Run Developer Seeds (admin user, example pages, etc.)
+ // 4. Run Developer Seeds (admin user, example pages, etc.)
await runDeveloperSeeds();
- // 4. Run Custom Seeds - uncomment when custom seeds are defined
+ // 5. Run Custom Seeds - uncomment when custom seeds are defined
// try {
// const customSeeds = await import("./custom-seeds.js");
// await customSeeds.runCustomSeeds();
diff --git a/packages/db/src/seeds/settings.ts b/packages/db/src/seeds/settings.ts
new file mode 100644
index 0000000..da1388f
--- /dev/null
+++ b/packages/db/src/seeds/settings.ts
@@ -0,0 +1,53 @@
+import { db } from "../index.js";
+import { settings } from "../schema/index.js";
+import type { SettingKey } from "@repo/types";
+
+/**
+ * Seeds the default settings into the database.
+ * Only initializes settings that don't already exist.
+ */
+export async function seedSettings() {
+ console.log("\nSeeding default settings...");
+
+ try {
+ // Dynamically import to avoid circular dependencies
+ const settingsModule = await import("@repo/types");
+ const DEFAULT_SETTINGS = settingsModule.DEFAULT_SETTINGS;
+
+ // Get all defined setting keys
+ const allKeys = Object.keys(DEFAULT_SETTINGS) as SettingKey[];
+
+ // Check which settings already exist in the database
+ const existingSettings = await db.query.settings.findMany({
+ columns: { key: true },
+ });
+ const existingKeys = new Set(existingSettings.map((s) => s.key));
+
+ // Filter out keys that already exist
+ const keysToCreate = allKeys.filter((key) => !existingKeys.has(key));
+
+ // Create missing settings with default values
+ if (keysToCreate.length > 0) {
+ for (const key of keysToCreate) {
+ const setting = DEFAULT_SETTINGS[key];
+
+ await db.insert(settings).values({
+ key: key as SettingKey,
+ value: setting.value,
+ description: setting.description,
+ });
+
+ console.log(` ✓ Initialized setting: ${key}`);
+ }
+
+ console.log(` ✅ Created ${keysToCreate.length} default settings`);
+ } else {
+ console.log(" ✓ All settings already exist, nothing to seed");
+ }
+
+ return true;
+ } catch (error) {
+ console.error(" ❌ Error seeding settings:", error);
+ return false;
+ }
+}
diff --git a/packages/tailwind-config/tailwind.config.ts b/packages/tailwind-config/tailwind.config.ts
index eb734a1..67ede15 100644
--- a/packages/tailwind-config/tailwind.config.ts
+++ b/packages/tailwind-config/tailwind.config.ts
@@ -24,7 +24,7 @@ const sharedConfig: Omit = {
accent: "var(--color-text-accent)",
},
border: {
- DEFAULT: "var(--color-border)",
+ DEFAULT: "var(--color-border-default)",
light: "var(--color-border-light)",
dark: "var(--color-border-dark)",
accent: "var(--color-border-accent)",
diff --git a/packages/types/package.json b/packages/types/package.json
index 9369233..f8d4790 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -4,10 +4,18 @@
"type": "module",
"private": true,
"exports": {
- ".": "./src/index.ts"
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./settings": {
+ "types": "./dist/settings.d.ts",
+ "import": "./dist/settings.js"
+ }
},
- "types": "./src/index.ts",
+ "types": "./dist/index.d.ts",
"scripts": {
+ "build": "tsc",
"lint": "eslint . --max-warnings 0",
"clean": "rm -rf dist .next",
"fullclean": "rm -rf dist .next .turbo node_modules"
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 23d4a2c..4fb1364 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -1,2 +1,2 @@
// Export your package components here
-export const name = "types";
+export * from "./settings.js";
diff --git a/packages/types/src/settings.ts b/packages/types/src/settings.ts
new file mode 100644
index 0000000..e470ed5
--- /dev/null
+++ b/packages/types/src/settings.ts
@@ -0,0 +1,319 @@
+/**
+ * Type definitions for NextWiki settings system
+ * All application settings are stored in the database and accessed through this type system
+ */
+
+/**
+ * Setting value types
+ */
+export type StringSetting = { type: "string"; value: string };
+export type NumberSetting = { type: "number"; value: number };
+export type BooleanSetting = { type: "boolean"; value: boolean };
+export type SelectSetting = {
+ type: "select";
+ value: T;
+ options: T[];
+};
+export type JsonSetting = { type: "json"; value: Record };
+
+/**
+ * Setting metadata
+ */
+export interface SettingMeta {
+ description: string;
+ category: SettingCategory;
+ defaultValue: unknown;
+ isSecret?: boolean;
+ requiresRestart?: boolean;
+}
+
+/**
+ * Setting categories for UI organization
+ */
+export type SettingCategory =
+ | "general"
+ | "auth"
+ | "appearance"
+ | "editor"
+ | "search"
+ | "advanced";
+
+/**
+ * Combined setting definition with value and metadata
+ */
+export type SettingDefinition = T & SettingMeta;
+
+/**
+ * Any valid setting value type
+ */
+export type SettingValueType =
+ | StringSetting
+ | NumberSetting
+ | BooleanSetting
+ | SelectSetting
+ | JsonSetting;
+
+/**
+ * All application settings with their types and metadata
+ */
+export interface SettingsDefinitions {
+ // General settings
+ "site.title": SettingDefinition;
+ "site.description": SettingDefinition;
+ "site.url": SettingDefinition;
+ "site.logo": SettingDefinition;
+
+ // Authentication settings
+ "auth.allowRegistration": SettingDefinition;
+ "auth.requireEmailVerification": SettingDefinition;
+ "auth.defaultUserGroup": SettingDefinition;
+ "auth.minPasswordLength": SettingDefinition;
+
+ // Appearance settings
+ "appearance.defaultTheme": SettingDefinition<
+ SelectSetting<"light" | "dark" | "system">
+ >;
+ "appearance.accentColor": SettingDefinition;
+ "appearance.showSidebar": SettingDefinition;
+
+ // Editor settings
+ "editor.defaultType": SettingDefinition>;
+ "editor.spellcheck": SettingDefinition;
+ "editor.autosaveInterval": SettingDefinition;
+
+ // Search settings
+ "search.fuzzyMatching": SettingDefinition;
+ "search.maxResults": SettingDefinition;
+ "search.minScore": SettingDefinition;
+
+ // Advanced settings
+ "advanced.assetStorageProvider": SettingDefinition<
+ SelectSetting<"local" | "s3" | "azure">
+ >;
+ "advanced.storageConfig": SettingDefinition;
+ "advanced.maxUploadSize": SettingDefinition;
+}
+
+/**
+ * Type for a setting key
+ */
+export type SettingKey = keyof SettingsDefinitions;
+
+/**
+ * Type for getting a setting value based on its key
+ */
+export type SettingValue =
+ SettingsDefinitions[K]["value"];
+
+/**
+ * Type for settings stored in the database
+ */
+export interface DbSetting {
+ key: K;
+ value: SettingValue;
+ description: string;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+/**
+ * Type for settings history entry
+ */
+export interface SettingHistoryEntry {
+ id: number;
+ settingKey: K;
+ previousValue: SettingValue;
+ changedById: number | null;
+ changedAt: Date;
+ changeReason: string | null;
+}
+
+/**
+ * Default values for all settings
+ */
+export const DEFAULT_SETTINGS: {
+ [K in SettingKey]: SettingsDefinitions[K];
+} = {
+ // General settings
+ "site.title": {
+ type: "string",
+ value: "NextWiki",
+ description: "The title of your wiki site",
+ category: "general",
+ defaultValue: "NextWiki",
+ },
+ "site.description": {
+ type: "string",
+ value: "A modern wiki built with Next.js",
+ description: "A short description of your wiki site",
+ category: "general",
+ defaultValue: "A modern wiki built with Next.js",
+ },
+ "site.url": {
+ type: "string",
+ value: "http://localhost:3000",
+ description: "The full URL of your wiki site (without trailing slash)",
+ category: "general",
+ defaultValue: "http://localhost:3000",
+ },
+ "site.logo": {
+ type: "string",
+ value: "/assets/images/logo.svg",
+ description: "Path to the logo image file",
+ category: "general",
+ defaultValue: "/assets/images/logo.svg",
+ },
+
+ // Authentication settings
+ "auth.allowRegistration": {
+ type: "boolean",
+ value: true,
+ description: "Allow new user registrations",
+ category: "auth",
+ defaultValue: true,
+ },
+ "auth.requireEmailVerification": {
+ type: "boolean",
+ value: false,
+ description: "Require email verification before allowing login",
+ category: "auth",
+ defaultValue: false,
+ },
+ "auth.defaultUserGroup": {
+ type: "string",
+ value: "users",
+ description: "Default group for new users",
+ category: "auth",
+ defaultValue: "users",
+ },
+ "auth.minPasswordLength": {
+ type: "number",
+ value: 8,
+ description: "Minimum password length for new accounts",
+ category: "auth",
+ defaultValue: 8,
+ },
+
+ // Appearance settings
+ "appearance.defaultTheme": {
+ type: "select",
+ value: "system",
+ options: ["light", "dark", "system"],
+ description: "Default theme for new users",
+ category: "appearance",
+ defaultValue: "system",
+ },
+ "appearance.accentColor": {
+ type: "string",
+ value: "#0070f3",
+ description: "Accent color for UI elements (hex code)",
+ category: "appearance",
+ defaultValue: "#0070f3",
+ },
+ "appearance.showSidebar": {
+ type: "boolean",
+ value: true,
+ description: "Show sidebar navigation by default",
+ category: "appearance",
+ defaultValue: true,
+ },
+
+ // Editor settings
+ "editor.defaultType": {
+ type: "select",
+ value: "markdown",
+ options: ["markdown", "html"],
+ description: "Default editor type for new pages",
+ category: "editor",
+ defaultValue: "markdown",
+ },
+ "editor.spellcheck": {
+ type: "boolean",
+ value: true,
+ description: "Enable spellchecking in the editor",
+ category: "editor",
+ defaultValue: true,
+ },
+ "editor.autosaveInterval": {
+ type: "number",
+ value: 30,
+ description: "Autosave interval in seconds (0 to disable)",
+ category: "editor",
+ defaultValue: 30,
+ },
+
+ // Search settings
+ "search.fuzzyMatching": {
+ type: "boolean",
+ value: true,
+ description: "Enable fuzzy matching in search results",
+ category: "search",
+ defaultValue: true,
+ },
+ "search.maxResults": {
+ type: "number",
+ value: 20,
+ description: "Maximum number of search results to display",
+ category: "search",
+ defaultValue: 20,
+ },
+ "search.minScore": {
+ type: "number",
+ value: 0.3,
+ description: "Minimum relevance score for search results (0-1)",
+ category: "search",
+ defaultValue: 0.3,
+ },
+
+ // Advanced settings
+ "advanced.assetStorageProvider": {
+ type: "select",
+ value: "local",
+ options: ["local", "s3", "azure"],
+ description: "Provider for storing uploaded assets",
+ category: "advanced",
+ defaultValue: "local",
+ requiresRestart: true,
+ },
+ "advanced.storageConfig": {
+ type: "json",
+ value: {},
+ description: "Configuration for the selected storage provider",
+ category: "advanced",
+ defaultValue: {},
+ isSecret: true,
+ },
+ "advanced.maxUploadSize": {
+ type: "number",
+ value: 5242880, // 5MB in bytes
+ description: "Maximum file upload size in bytes",
+ category: "advanced",
+ defaultValue: 5242880,
+ },
+};
+
+/**
+ * Type-safe helper to get setting default value
+ */
+export function getDefaultSetting(
+ key: K
+): SettingValue {
+ return DEFAULT_SETTINGS[key].value;
+}
+
+/**
+ * Type-safe helper to get setting metadata
+ */
+export function getSettingMeta(
+ key: K
+): Omit, "type" | "value" | "options"> {
+ // Extract just the metadata fields
+ const { description, category, defaultValue, isSecret, requiresRestart } =
+ DEFAULT_SETTINGS[key];
+ return { description, category, defaultValue, isSecret, requiresRestart };
+}
+
+/**
+ * Export default settings as index
+ */
+export default DEFAULT_SETTINGS;
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 189bb2e..ecc4039 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -36,6 +36,7 @@
"@radix-ui/react-avatar": "^1.1.6",
"@radix-ui/react-checkbox": "^1.2.2",
"@radix-ui/react-dialog": "^1.1.10",
+ "@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-popover": "^1.1.10",
@@ -55,8 +56,10 @@
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
- "@repo/typescript-config": "workspace:*",
"@repo/tailwind-config": "workspace:*",
+ "@repo/typescript-config": "workspace:*",
+ "@tailwindcss/cli": "^4.1.4",
+ "@tailwindcss/typography": "^0.5.16",
"@turbo/gen": "^2.5.0",
"@types/node": "^22.14.1",
"@types/react": "^19.1.2",
@@ -69,8 +72,6 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwindcss": "^4.1.4",
- "@tailwindcss/cli": "^4.1.4",
- "typescript": "5.8.3",
- "@tailwindcss/typography": "^0.5.16"
+ "typescript": "5.8.3"
}
}
diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx
index 2d30128..661bbdf 100644
--- a/packages/ui/src/components/button.tsx
+++ b/packages/ui/src/components/button.tsx
@@ -4,8 +4,32 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../utils";
+// Simple SVG Spinner
+const Spinner = ({ className }: { className?: string }) => (
+
+
+
+
+);
+
const buttonVariants = cva(
- "inline-flex items-center justify-center whitespace-nowrap text-sm font-bold ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ "inline-flex items-center justify-center whitespace-nowrap text-sm font-bold ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 relative",
{
variants: {
variant: {
@@ -84,6 +108,7 @@ export interface ButtonProps
extends Omit, "color">,
Omit, "color"> {
asChild?: boolean;
+ loading?: boolean;
color?:
| "info"
| "error"
@@ -110,6 +135,7 @@ const Button = React.forwardRef(
asChild = false,
rounded,
elevation,
+ loading = false,
...props
},
ref
@@ -128,6 +154,39 @@ const Button = React.forwardRef(
} as React.CSSProperties)
: undefined;
+ const spinnerSizeClass =
+ size === "sm" ? "h-3 w-3" : size === "icon" ? "h-5 w-5" : "h-4 w-4";
+
+ // If loading, always render a button element with the spinner, ignore asChild and children
+ if (loading) {
+ return (
+
+
+
+
+ {/* Render original children invisibly to maintain layout spacing if needed, but ensure only one child */}
+ {props.children}
+
+ );
+ }
+
+ // Original rendering logic when not loading
return (
(
)}
ref={ref}
style={customColorStyle}
+ disabled={props.disabled} // Use original disabled prop
{...props}
- />
+ >
+ {/* Render children directly when not loading */}
+ {props.children}
+
);
}
);
diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx
index 4796fa8..ca760ad 100644
--- a/packages/ui/src/components/dialog.tsx
+++ b/packages/ui/src/components/dialog.tsx
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+));
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+ svg]:size-4 [&>svg]:shrink-0",
+ inset && "pl-8",
+ className
+ )}
+ {...props}
+ />
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+};
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index 2866b23..a27ef82 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -20,3 +20,4 @@ export * from "./table";
export * from "./tabs";
export * from "./textarea";
export * from "./tooltip";
+export * from "./dropdown";
diff --git a/packages/ui/src/components/table.tsx b/packages/ui/src/components/table.tsx
index dd54738..4cbc540 100644
--- a/packages/ui/src/components/table.tsx
+++ b/packages/ui/src/components/table.tsx
@@ -20,7 +20,11 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+
));
TableHeader.displayName = "TableHeader";
@@ -43,7 +47,7 @@ const TableFooter = React.forwardRef<
tr]:last:border-b-0",
+ "border-border-default border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
@@ -58,7 +62,7 @@ const TableRow = React.forwardRef<
(({ className, ...props }, ref) => (
));
diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx
index d67a30c..2077325 100644
--- a/packages/ui/src/components/tabs.tsx
+++ b/packages/ui/src/components/tabs.tsx
@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
=12'}
+ peerDependencies:
+ react: '>=16.8'
+ react-dom: '>=16.8'
+
+ '@tanstack/table-core@8.21.3':
+ resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
+ engines: {node: '>=12'}
+
'@textea/json-viewer@3.5.0':
resolution: {integrity: sha512-codh4YXkWPtMjucpn1krGxyJLQA2QhpfM0y3Sur7D/mONOnESoI5ZLmX3ZFo9heXPndDQgzCHsjpErvkN5+hxw==}
peerDependencies:
@@ -7132,6 +7184,15 @@ packages:
peerDependencies:
react: ^19.1.0
+ react-intersection-observer@9.16.0:
+ resolution: {integrity: sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==}
+ peerDependencies:
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -10408,6 +10469,21 @@ snapshots:
'@types/react': 19.1.2
'@types/react-dom': 19.1.2(@types/react@19.1.2)
+ '@radix-ui/react-dropdown-menu@2.1.12(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.2
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+ '@radix-ui/react-menu': 2.1.12(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.2)(react@19.1.0)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ optionalDependencies:
+ '@types/react': 19.1.2
+ '@types/react-dom': 19.1.2(@types/react@19.1.2)
+
'@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.2)(react@19.1.0)':
dependencies:
react: 19.1.0
@@ -10445,6 +10521,32 @@ snapshots:
'@types/react': 19.1.2
'@types/react-dom': 19.1.2(@types/react@19.1.2)
+ '@radix-ui/react-menu@2.1.12(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.2
+ '@radix-ui/react-collection': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+ '@radix-ui/react-dismissable-layer': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.2)(react@19.1.0)
+ '@radix-ui/react-focus-scope': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+ '@radix-ui/react-popper': 1.2.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-portal': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-roving-focus': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0)
+ aria-hidden: 1.2.4
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ react-remove-scroll: 2.6.3(@types/react@19.1.2)(react@19.1.0)
+ optionalDependencies:
+ '@types/react': 19.1.2
+ '@types/react-dom': 19.1.2(@types/react@19.1.2)
+
'@radix-ui/react-popover@1.1.11(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -11001,6 +11103,14 @@ snapshots:
'@tanstack/query-core': 5.74.4
react: 19.1.0
+ '@tanstack/react-table@8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@tanstack/table-core': 8.21.3
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+
+ '@tanstack/table-core@8.21.3': {}
+
'@textea/json-viewer@3.5.0(@emotion/react@11.14.0(@types/react@19.1.2)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.2)(react@19.1.0))(@types/react@19.1.2)(react@19.1.0))(@mui/material@5.17.1(@emotion/react@11.14.0(@types/react@19.1.2)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.2)(react@19.1.0))(@types/react@19.1.2)(react@19.1.0))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@emotion/react': 11.14.0(@types/react@19.1.2)(react@19.1.0)
@@ -12764,8 +12874,8 @@ snapshots:
'@typescript-eslint/parser': 8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3)
eslint: 9.25.1(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.25.1(jiti@2.4.2))
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.25.1(jiti@2.4.2))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-react: 7.37.5(eslint@9.25.1(jiti@2.4.2))
eslint-plugin-react-hooks: 5.2.0(eslint@9.25.1(jiti@2.4.2))
@@ -12788,7 +12898,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.25.1(jiti@2.4.2)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.0
@@ -12799,22 +12909,22 @@ snapshots:
tinyglobby: 0.2.13
unrs-resolver: 1.6.4
optionalDependencies:
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.25.1(jiti@2.4.2))
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.25.1(jiti@2.4.2)):
+ eslint-module-utils@2.12.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3)
eslint: 9.25.1(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.25.1(jiti@2.4.2))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.25.1(jiti@2.4.2)):
+ eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.8
@@ -12825,7 +12935,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.25.1(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.25.1(jiti@2.4.2))
+ eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2)))(eslint@9.25.1(jiti@2.4.2))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -15711,6 +15821,12 @@ snapshots:
react: 19.1.0
scheduler: 0.26.0
+ react-intersection-observer@9.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ dependencies:
+ react: 19.1.0
+ optionalDependencies:
+ react-dom: 19.1.0(react@19.1.0)
+
react-is@16.13.1: {}
react-is@18.3.1: {}
diff --git a/schema.dbml b/schema.dbml
new file mode 100644
index 0000000..e54d920
--- /dev/null
+++ b/schema.dbml
@@ -0,0 +1,251 @@
+// 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 MODULES {
+ id int [pk, increment]
+ name varchar(50) [unique, not null]
+ description text
+ created_at timestamp [default: `now()`]
+}
+
+Table ACTIONS {
+ id int [pk, increment]
+ name varchar(50) [unique, not null]
+ description text
+ created_at timestamp [default: `now()`]
+}
+
+Table PERMISSIONS {
+ id int [pk, increment]
+ module_id int [not null, ref: > MODULES.id]
+ resource varchar(50) [not null]
+ action_id int [not null, ref: > ACTIONS.id]
+ 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_id int [not null, ref: > MODULES.id]
+ created_at timestamp [default: `now()`]
+
+ indexes {
+ (group_id, module_id) [pk]
+ group_module_permissions_idx (group_id, module_id)
+ }
+}
+
+Table GROUP_ACTION_PERMISSIONS {
+ group_id int [not null, ref: > GROUPS.id]
+ action_id int [not null, ref: > ACTIONS.id]
+ created_at timestamp [default: `now()`]
+
+ indexes {
+ (group_id, action_id) [pk]
+ group_action_permissions_idx (group_id, action_id)
+ }
+}
+
+Table PATH_PERMISSIONS {
+ id int [pk, increment]
+ path_pattern varchar(1000) [not null, note: 'Path pattern using SQL LIKE syntax (e.g., /admin/%, /wiki/page, /docs/_%)']
+ group_id int [not null, ref: > GROUPS.id]
+ permission_id int [not null, ref: > PERMISSIONS.id]
+ permission_type varchar(10) [not null, default: 'allow', note: "'allow' or 'deny'"]
+ precedence int [not null, default: 0, note: 'Higher value takes precedence in case of conflicting patterns']
+ created_at timestamp [default: `now()`]
+
+ indexes {
+ // Index for efficient lookup based on group and potentially path prefix
+ path_perm_group_idx (group_id)
+ // Potentially add unique constraint depending on desired logic:
+ // unique_path_perm (group_id, permission_id, path_pattern, permission_type)
+ }
+}
+
+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)
+ }
+}
+
+Table SETTINGS {
+ key varchar(100) [pk, not null, note: 'Unique key for the setting']
+ value jsonb [note: 'Value of the setting, stored as JSONB'] // Note: Using jsonb type
+ description text [note: 'Description of what the setting controls']
+ created_at timestamp [default: `now()`]
+ updated_at timestamp [default: `now()`]
+
+ indexes {
+ settings_key_idx (key) [unique]
+ }
+}
+
+Table SETTINGS_HISTORY {
+ id int [pk, increment]
+ setting_key varchar(100) [not null, note: 'The key of the setting that was changed']
+ previous_value jsonb [note: 'Value of the setting *before* this change (JSONB)']
+ changed_by_id int [ref: > USERS.id, note: 'User who made the change']
+ changed_at timestamp [default: `now()`, note: 'Timestamp of the change']
+ change_reason text [note: 'Optional reason for the change']
+
+ indexes {
+ settings_history_key_idx (setting_key)
+ settings_history_user_idx (changed_by_id)
+ settings_history_time_idx (changed_at)
+ }
+}
\ No newline at end of file