mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Merge Tag 'v0.2.28'
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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
|
## Version 0.2.27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import ClientWrapper from './ClientWrapper'
|
import ClientWrapper from './ClientWrapper'
|
||||||
import Header from './Header'
|
import Header from './Header'
|
||||||
import Navigation from './Navigation'
|
import Navigation from './Navigation'
|
||||||
|
import PermissionError from './PermissionError'
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
<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">
|
<Header className="sticky top-0 z-50" />
|
||||||
<Navigation position='main' />
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="flex-1 flex flex-col">
|
<Navigation position='main' />
|
||||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
<div className="flex-1 flex flex-col">
|
||||||
{/* responsive container (optimized for mobile) */}
|
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900 relative">
|
||||||
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
|
{/* responsive container (optimized for mobile) */}
|
||||||
|
<div className="mx-auto px-2 xs:px-4 py-8 max-w-sm xs:max-w-full">
|
||||||
<ClientWrapper>
|
<ClientWrapper>
|
||||||
|
<PermissionError />
|
||||||
{children}
|
{children}
|
||||||
</ClientWrapper>
|
</ClientWrapper>
|
||||||
</div>
|
</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",
|
"name": "habittrove",
|
||||||
"version": "0.2.27",
|
"version": "0.2.28",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
Reference in New Issue
Block a user