mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-20 22:24:28 +01:00
Merge Tag 'v0.2.28'
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## Version 0.2.28
|
||||
|
||||
### Added
|
||||
|
||||
* Server permission checking system to validate data directory access on startup
|
||||
* Permission error display with troubleshooting guidance and recheck functionality
|
||||
|
||||
## Version 0.2.27
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import ClientWrapper from './ClientWrapper'
|
||||
import Header from './Header'
|
||||
import Navigation from './Navigation'
|
||||
import PermissionError from './PermissionError'
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
||||
<Header className="sticky top-0 z-50" />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Navigation position='main' />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
||||
{/* responsive container (optimized for mobile) */}
|
||||
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
|
||||
|
||||
<Header className="sticky top-0 z-50" />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Navigation position='main' />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
||||
{/* responsive container (optimized for mobile) */}
|
||||
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
|
||||
<ClientWrapper>
|
||||
<PermissionError />
|
||||
{children}
|
||||
</ClientWrapper>
|
||||
</div>
|
||||
|
||||
43
components/PermissionError.tsx
Normal file
43
components/PermissionError.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { checkStartupPermissions } from '@/lib/startup-checks'
|
||||
import RecheckButton from './RecheckButton'
|
||||
|
||||
export default async function PermissionError() {
|
||||
const permissionResult = await checkStartupPermissions()
|
||||
|
||||
// If everything is fine, render nothing
|
||||
if (permissionResult.success) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get error message
|
||||
const getErrorMessage = () => {
|
||||
return permissionResult.error?.message || 'Unknown permission error occurred.'
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle className="font-bold">Permission Error</AlertTitle>
|
||||
<AlertDescription className="mt-2 flex flex-col">
|
||||
<div className="space-y-3">
|
||||
<span className="text-sm">
|
||||
{getErrorMessage()}{" "}
|
||||
<a
|
||||
href="https://docs.habittrove.com/troubleshooting"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-red-300"
|
||||
>
|
||||
Troubleshooting Guide
|
||||
</a>
|
||||
</span>
|
||||
<div>
|
||||
<RecheckButton />
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
22
components/RecheckButton.tsx
Normal file
22
components/RecheckButton.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
export default function RecheckButton() {
|
||||
const handleRecheck = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleRecheck}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-red-50 border-red-300 text-red-700 hover:bg-red-100"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Recheck
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
61
components/ui/alert.tsx
Normal file
61
components/ui/alert.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
warning:
|
||||
"border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-200 [&>svg]:text-yellow-600 dark:[&>svg]:text-yellow-400",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
205
lib/startup-checks.test.ts
Normal file
205
lib/startup-checks.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, test, beforeEach, mock } from 'bun:test'
|
||||
import { checkStartupPermissions } from './startup-checks'
|
||||
|
||||
// Mock the fs promises module
|
||||
const mockStat = mock()
|
||||
const mockWriteFile = mock()
|
||||
const mockReadFile = mock()
|
||||
const mockUnlink = mock()
|
||||
|
||||
mock.module('fs', () => ({
|
||||
promises: {
|
||||
stat: mockStat,
|
||||
writeFile: mockWriteFile,
|
||||
readFile: mockReadFile,
|
||||
unlink: mockUnlink,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('checkStartupPermissions', () => {
|
||||
beforeEach(() => {
|
||||
// Reset all mocks before each test
|
||||
mockStat.mockReset()
|
||||
mockWriteFile.mockReset()
|
||||
mockReadFile.mockReset()
|
||||
mockUnlink.mockReset()
|
||||
})
|
||||
|
||||
test('should return success when directory exists and has proper permissions', async () => {
|
||||
// Mock successful directory stat
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
})
|
||||
|
||||
// Mock successful file operations
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockReadFile.mockResolvedValue('permission-test')
|
||||
mockUnlink.mockResolvedValue(undefined)
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
|
||||
})
|
||||
|
||||
test('should return error when directory does not exist', async () => {
|
||||
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'))
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
path: 'data',
|
||||
message: 'Data directory \'data\' does not exist or is not accessible. Check volume mounts and permissions.',
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
})
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should return error when path exists but is not a directory', async () => {
|
||||
// Mock path exists but is a file, not directory
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
})
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
path: 'data',
|
||||
message: 'Path \'data\' exists but is not a directory. Please ensure the data directory is properly configured.',
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
})
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should return error when write permission fails', async () => {
|
||||
// Mock successful directory stat
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
})
|
||||
|
||||
// Mock write failure
|
||||
mockWriteFile.mockRejectedValue(new Error('EACCES: permission denied'))
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
path: 'data',
|
||||
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
})
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||
expect(mockReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should return error when read permission fails', async () => {
|
||||
// Mock successful directory stat and write
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
})
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
|
||||
// Mock read failure
|
||||
mockReadFile.mockRejectedValue(new Error('EACCES: permission denied'))
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
path: 'data',
|
||||
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
})
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||
})
|
||||
|
||||
test('should return error when read content does not match written content', async () => {
|
||||
// Mock successful directory stat and write
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
})
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
|
||||
// Mock read with different content
|
||||
mockReadFile.mockResolvedValue('different-content')
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
path: 'data',
|
||||
message: 'Data integrity check failed in \'data\'. File system may be corrupted or have inconsistent behavior.',
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
})
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||
expect(mockUnlink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should return error when cleanup (unlink) fails', async () => {
|
||||
// Mock successful directory stat, write, and read
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
})
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockReadFile.mockResolvedValue('permission-test')
|
||||
|
||||
// Mock cleanup failure
|
||||
mockUnlink.mockRejectedValue(new Error('EACCES: permission denied'))
|
||||
|
||||
const result = await checkStartupPermissions()
|
||||
|
||||
// Should return error since cleanup failed and is part of the try-catch block
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
path: 'data',
|
||||
message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
})
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
|
||||
})
|
||||
|
||||
test('should use correct file paths', async () => {
|
||||
// Mock successful operations
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
})
|
||||
mockWriteFile.mockResolvedValue(undefined)
|
||||
mockReadFile.mockResolvedValue('permission-test')
|
||||
mockUnlink.mockResolvedValue(undefined)
|
||||
|
||||
await checkStartupPermissions()
|
||||
|
||||
// Verify the correct paths are used
|
||||
expect(mockStat).toHaveBeenCalledWith('data')
|
||||
expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
|
||||
expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
|
||||
expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
|
||||
})
|
||||
})
|
||||
73
lib/startup-checks.ts
Normal file
73
lib/startup-checks.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const DEFAULT_DATA_DIR = 'data'
|
||||
|
||||
/**
|
||||
* Checks startup permissions for the data directory
|
||||
*/
|
||||
interface StartupPermissionResult {
|
||||
success: boolean
|
||||
error?: { path: string; message: string; type?: 'writable_data_dir' }
|
||||
}
|
||||
|
||||
export async function checkStartupPermissions(): Promise<StartupPermissionResult> {
|
||||
const dirPath = DEFAULT_DATA_DIR
|
||||
|
||||
// Check if directory exists and is accessible
|
||||
try {
|
||||
const stats = await fs.stat(dirPath)
|
||||
if (!stats.isDirectory()) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
path: dirPath,
|
||||
message: `Path '${dirPath}' exists but is not a directory. Please ensure the data directory is properly configured.`,
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (statError) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
path: dirPath,
|
||||
message: `Data directory '${dirPath}' does not exist or is not accessible. Check volume mounts and permissions.`,
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test read/write permissions with a temporary file
|
||||
const testFilePath = join(dirPath, '.habittrove-permission-test')
|
||||
const testContent = 'permission-test'
|
||||
|
||||
try {
|
||||
await fs.writeFile(testFilePath, testContent)
|
||||
const readContent = await fs.readFile(testFilePath, 'utf8')
|
||||
|
||||
if (readContent !== testContent) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
path: dirPath,
|
||||
message: `Data integrity check failed in '${dirPath}'. File system may be corrupted or have inconsistent behavior.`,
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.unlink(testFilePath)
|
||||
return { success: true }
|
||||
|
||||
} catch (rwError) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
path: dirPath,
|
||||
message: `Insufficient read/write permissions for data directory '${dirPath}'. Check file permissions and ownership.`,
|
||||
type: 'writable_data_dir'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.2.27",
|
||||
"version": "0.2.28",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
Reference in New Issue
Block a user