102 Commits

Author SHA1 Message Date
7d74821f29 Merge pull request #23 from LordMathis/chore/update-readme
Update README
2024-11-30 22:11:17 +01:00
66415738b6 Add upgrading section 2024-11-30 22:07:58 +01:00
dff66ab1b4 Update README 2024-11-30 22:05:26 +01:00
05933f8c22 Merge pull request #22 from LordMathis/fix/missing-git-email
Fix missing commit author name and email
2024-11-30 21:36:14 +01:00
68ad70b0b7 Add commit author and email to frontend settings 2024-11-30 21:21:54 +01:00
d5d2792689 Fix missing admin user setup 2024-11-30 19:38:47 +01:00
31d00681a1 Update handlers and db 2024-11-30 16:12:51 +01:00
4359267d55 Add commit name and commit email to workspace model 2024-11-30 13:56:07 +01:00
c07f19bbb3 Merge migrations 2024-11-30 13:55:29 +01:00
325cedc235 Add commit name and commit email to git client 2024-11-30 12:54:47 +01:00
453c022959 Merge pull request #21 from LordMathis/chore/split-main
Split main into app package
2024-11-30 12:11:06 +01:00
de2a9364ab Split main into app package 2024-11-30 12:06:34 +01:00
cfa048b8de Merge pull request #20 from LordMathis/chore/backend-test
Implement backend tests
2024-11-30 11:51:35 +01:00
842513f8a5 Add test tags to github workflow 2024-11-30 11:48:57 +01:00
ae48761d34 Implement workspace handlers integration tests 2024-11-30 11:44:17 +01:00
8bed3614ee Fix user deletion handler 2024-11-30 00:12:14 +01:00
2a53be5a6e Fix user update tests 2024-11-30 00:09:01 +01:00
af9ab42969 Add integration tests for use handlers 2024-11-29 23:57:17 +01:00
d47b601447 Rename mock secrets 2024-11-29 23:15:37 +01:00
1ddf93a8be Implement git handlers integration test 2024-11-29 23:14:36 +01:00
6aa3fd6c65 Add script for generating single file documentation 2024-11-28 22:05:27 +01:00
9b4db528ca Fix lint issues 2024-11-28 21:55:01 +01:00
f5d616fe00 Update documentation 2024-11-28 21:53:03 +01:00
51ed9e53a4 Implement static handler tests 2024-11-28 21:33:28 +01:00
3fb40a8817 Implement file handlers integration tests 2024-11-28 21:18:47 +01:00
91489ca633 Update path validation error handling 2024-11-28 21:18:30 +01:00
fbb8fa3a60 Implement admin handlers integration test 2024-11-27 21:28:59 +01:00
4ddf1f570f Implement auth handler integration test 2024-11-26 22:50:43 +01:00
e8868dde39 Test users and workspaces 2024-11-25 21:58:16 +01:00
32bd202d6f Implement session and system tests 2024-11-25 21:44:43 +01:00
9ac047d440 Delete unused test case fixture 2024-11-25 20:54:49 +01:00
1e7cd0934e Add migrations tests 2024-11-24 00:17:08 +01:00
9d81b1036d Refactor db init 2024-11-23 22:33:55 +01:00
9f241271a7 Test context package 2024-11-23 22:15:25 +01:00
8f2f8b30dd Test secrets package 2024-11-23 21:28:15 +01:00
1150c4ba39 Test config package 2024-11-23 16:36:29 +01:00
ebdd7bd741 Implement auth package tests 2024-11-23 00:29:26 +01:00
b3ec4e136c Implement auth tests 2024-11-22 23:17:59 +01:00
807e96a76c Rework db package to make it testable 2024-11-21 22:36:12 +01:00
2faefb6db5 Implement JWTManager interface 2024-11-21 21:25:29 +01:00
435dce89d9 Add go test workflow 2024-11-21 19:42:50 +01:00
6cb5aec372 Implement storage git tests 2024-11-20 22:06:38 +01:00
7396b57a5d Rework gitutils package to make it testable 2024-11-19 22:43:24 +01:00
53e52bfdb5 Test workspace 2024-11-19 22:17:00 +01:00
de2c9a6d0c Implement files test 2024-11-19 21:44:06 +01:00
2fe642ac61 Rework mock filesystem 2024-11-19 21:43:52 +01:00
408746187e Implement test list files 2024-11-14 22:11:40 +01:00
e4510298ed Rename filesystem interfaces and structs 2024-11-14 21:13:45 +01:00
5311d2e144 Move storage to separate file 2024-11-13 22:34:22 +01:00
6a9461d928 Rename fs variable 2024-11-13 22:32:43 +01:00
93963b1867 Refactor filesystem to make it testable 2024-11-13 22:31:04 +01:00
52ffb17e2d Merge pull request #19 from LordMathis/chore/rename-folders
Rename root folders
2024-11-12 21:28:42 +01:00
fb1c9a499f Rename root folders 2024-11-12 21:25:02 +01:00
f4c21edca0 Merge pull request #18 from LordMathis/feat/show-hidden-setting
Implement show hidden files setting
2024-11-12 21:08:23 +01:00
1b58b693d0 Add show hidden files toggle to settings 2024-11-12 20:27:56 +01:00
d11525732d Filter hidden files on frontend 2024-11-12 20:15:12 +01:00
03cdb133e7 Fix get workspace db query 2024-11-12 20:14:58 +01:00
bac4702771 Add show_hidden_files filed to workspace 2024-11-11 22:24:27 +01:00
f3f3cb7371 Merge pull request #17 from LordMathis/feat/security-hardening
Implement Rate Limit, Secure Headers and CORS
2024-11-10 21:09:02 +01:00
d4c671caa7 Increase default rate limit 2024-11-10 20:56:04 +01:00
29b35f6b91 Add password length check 2024-11-10 20:49:07 +01:00
e275b45c86 Add secure headers and cors middlewares 2024-11-10 20:43:24 +01:00
77d9abb691 Implement rate limiting 2024-11-10 18:12:25 +01:00
8cf850a62c Merge pull request #16 from LordMathis/feat/admin-dashboard
Admin dashboard
2024-11-10 15:16:42 +01:00
5e2d434b4b Implement admin dash workspaces tab 2024-11-10 15:03:51 +01:00
148001be43 Implement admin edit user 2024-11-10 00:05:32 +01:00
33bc28560f Update admin stats 2024-11-09 23:33:07 +01:00
118591df62 Unify errors 2024-11-09 23:12:52 +01:00
7b1da94e8a Implement file system stats 2024-11-09 23:11:23 +01:00
9688b2d528 Split filesystem.go file 2024-11-09 21:59:04 +01:00
ebf32e775c Remove not working stats 2024-11-08 23:58:57 +01:00
dd3ea9f65f Improve admin dashboard 2024-11-08 23:49:12 +01:00
51751a5af6 Load users in AdminDashboard 2024-11-07 22:12:37 +01:00
0480c165ae Implement admin api handlers 2024-11-07 21:32:09 +01:00
24f877e50b Initial admin dashboard layout 2024-11-06 23:34:37 +01:00
adf5287db2 Merge pull request #15 from LordMathis/chore/componants-structure
Restructure components
2024-11-06 23:16:05 +01:00
0f6dcd3a60 Split AccountSettings 2024-11-06 23:12:38 +01:00
7f8c40c3a2 Reorganize components 2024-11-06 22:31:29 +01:00
64029615ea Merge pull request #14 from LordMathis/feat/user-auth
User authentication and account settings
2024-11-06 21:56:48 +01:00
1a14c06be2 Retrieve pass hash from db 2024-11-06 21:52:46 +01:00
48f75b3839 Update AccountSettings layout 2024-11-06 21:51:45 +01:00
e56378f1f0 Imlement user update on frontend 2024-11-05 21:56:08 +01:00
505b93ff09 Implement update and delete account handlers 2024-11-05 21:49:09 +01:00
8b8bfaa8c8 Add account settings 2024-11-05 21:03:25 +01:00
9581e32e06 Implement frontend logout 2024-11-04 22:26:05 +01:00
771650d66e Reset file when workspace switch 2024-11-04 22:09:11 +01:00
69afef15ec Make logging in work on frontend 2024-11-04 21:51:38 +01:00
9cdbf9fec8 Add initial frontend auth implementation 2024-11-03 23:16:57 +01:00
fae628c02b Remove user id from frintend api call 2024-11-03 22:14:17 +01:00
927d1feb05 Split user and workspace contexts 2024-11-03 22:02:39 +01:00
c8cc854fd6 Rework request context handler 2024-11-03 19:17:10 +01:00
dfd9544fba Reorganize handlers 2024-11-03 17:41:17 +01:00
72680abdf4 Setup api auth middleware 2024-11-01 17:04:24 +01:00
46eeb18a31 Setup jwt signing key 2024-11-01 17:04:08 +01:00
34868c53eb Update api for auth 2024-11-01 15:43:04 +01:00
be0f97ab24 Update db for auth 2024-11-01 15:42:46 +01:00
ce245c980a Implement jwt auth backend 2024-11-01 15:42:31 +01:00
ea91d8d608 Merge pull request #13 from LordMathis/fix/package-json
Fix wrong package.json
2024-10-31 23:17:02 +01:00
0b0a5253f0 Fix wrong package.json 2024-10-31 23:13:23 +01:00
f8cd11c9ac Merge pull request #12 from LordMathis/feat/remark
Migrate from react-markdown to remark
2024-10-31 23:04:42 +01:00
0ed2813643 Migrate to rehype-mathjax 2024-10-31 23:03:50 +01:00
3c855fce21 Migrate to remark 2024-10-31 22:41:50 +01:00
153 changed files with 13357 additions and 2462 deletions

34
.github/workflows/go-test.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Go Tests
on:
push:
branches:
- "*"
pull_request:
branches:
- main
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./server
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
cache: true
- name: Run Tests
run: go test -tags=test,integration ./... -v
- name: Run Tests with Race Detector
run: go test -tags=test,integration -race ./... -v

View File

@@ -14,6 +14,7 @@
"go.lintTool": "golangci-lint",
"go.lintOnSave": "package",
"go.formatTool": "goimports",
"go.testFlags": ["-tags=test,integration"],
"[go]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
@@ -23,6 +24,7 @@
},
"gopls": {
"usePlaceholders": true,
"staticcheck": true
"staticcheck": true,
"buildFlags": ["-tags", "test,integration"]
}
}

View File

@@ -1,18 +1,18 @@
# Stage 1: Build the frontend
FROM node:20 AS frontend-builder
WORKDIR /app
COPY frontend/package*.json ./
COPY app/package*.json ./
RUN npm ci
COPY frontend .
COPY app .
RUN npm run build
# Stage 2: Build the backend
FROM golang:1.23 AS backend-builder
WORKDIR /app
RUN apt-get update && apt-get install -y gcc musl-dev
COPY backend/go.mod backend/go.sum ./
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY backend .
COPY server .
RUN CGO_ENABLED=1 GOOS=linux go build -o novamd ./cmd/server
# Stage 3: Final stage

View File

@@ -9,7 +9,7 @@ Yet another markdown editor. Work in progress
- Git integration for version control
- Dark and light theme support
- Multiple workspaces
- Math equation support (KaTeX)
- Math equation support (MathJax)
- Code syntax highlighting
## Prerequisites
@@ -18,37 +18,54 @@ Yet another markdown editor. Work in progress
- Node.js 20 or later
- gcc (for go-sqlite3 package)
## Setup
## Configuration
Set the following environment variables:
NovaMD can be configured using environment variables. Here are the available configuration options:
- `CGO_ENABLED=1`: This is necessary for the go-sqlite3 package
- `NOVAMD_DB_PATH`: Path to the SQLite database file (default: "./sqlite.db")
- `NOVAMD_WORKDIR`: Directory for storing Markdown files (default: "./data")
- `NOVAMD_STATIC_PATH`: Path to the frontend build files (default: "../frontend/dist")
### Required Environment Variables
- `NOVAMD_ADMIN_EMAIL`: Email address for the admin account
- `NOVAMD_ADMIN_PASSWORD`: Password for the admin account
- `NOVAMD_ENCRYPTION_KEY`: Base64-encoded 32-byte key used for encrypting sensitive data
### Optional Environment Variables
- `NOVAMD_ENV`: Set to "development" to enable development mode
- `NOVAMD_DB_PATH`: Path to the SQLite database file (default: "./novamd.db")
- `NOVAMD_WORKDIR`: Working directory for application data (default: "./data")
- `NOVAMD_STATIC_PATH`: Path to static files (default: "../app/dist")
- `NOVAMD_PORT`: Port to run the server on (default: "8080")
- `NOVAMD_ADMIN_EMAIL`: Admin user email
- `NOVAMD_ADMIN_PASSWORD`: Admin user password
- `NOVAMD_ENCRYPTION_KEY`: 32-byte key for encrypting sensitive data
- `NOVAMD_APP_URL`: Full URL where the application is hosted
- `NOVAMD_CORS_ORIGINS`: Comma-separated list of allowed CORS origins
- `NOVAMD_JWT_SIGNING_KEY`: Key used for signing JWT tokens (autogenerated if not set)
- `NOVAMD_RATE_LIMIT_REQUESTS`: Number of allowed requests per window (default: 100)
- `NOVAMD_RATE_LIMIT_WINDOW`: Duration of the rate limit window (default: 15m)
To generate a secure encryption key you can use openssl:
### Generating Encryption Keys
The encryption key must be a base64-encoded 32-byte value. You can generate a secure encryption key using OpenSSL:
```bash
# Generate a random 32-byte key and encode it as base64
openssl rand -base64 32
```
## Running the Backend
Store the generated key securely - it will be needed to decrypt any data encrypted by the application. If the key is lost or changed, previously encrypted data will become inaccessible.
1. Navigate to the `backend` directory
2. Ensure all environment variables are set
3. Run the server:
## Running the backend server
1. Navigate to the `server` directory
2. Install dependecies: `go mod tidy`
3. Ensure all environment variables are set
4. Additionally set `CGO_ENABLED=1` (needed for sqlite3)
5. Run the server:
```
go run cmd/server/main.go
```
## Running the Frontend
## Running the frontend app
1. Navigate to the `frontend` directory
1. Navigate to the `app` directory
2. Install dependencies:
```
npm install
@@ -59,16 +76,16 @@ openssl rand -base64 32
```
The frontend will be available at `http://localhost:3000`
## Building for Production
## Building for production
1. Build the frontend:
1. Build the frontend app:
```
cd frontend
cd app
npm run build
```
2. Build the backend:
```
cd backend
cd server
go build -o novamd ./cmd/server
```
3. Set the `NOVAMD_STATIC_PATH` environment variable to point to the frontend build directory
@@ -86,3 +103,7 @@ A Dockerfile is provided for easy deployment. To build and run the Docker image:
```
docker run -p 8080:8080 -v /path/to/data:/app/data novamd
```
## Upgrading
Before first stable release (1.0.0) there is not upgrade path. You have to delete the database file and start over.

File diff suppressed because it is too large Load Diff

View File

@@ -35,14 +35,19 @@
"@react-hook/resize-observer": "^2.0.2",
"@tabler/icons-react": "^3.19.0",
"codemirror": "^6.0.1",
"katex": "^0.16.11",
"react": "^18.3.1",
"react-arborist": "^3.4.0",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0"
"rehype-mathjax": "^6.0.0",
"rehype-prism": "^2.3.3",
"rehype-react": "^8.0.0",
"remark": "^15.0.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@types/react": "^18.2.67",

View File

@@ -2,15 +2,37 @@ import React from 'react';
import { MantineProvider, ColorSchemeScript } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals';
import Layout from './components/Layout';
import Layout from './components/layout/Layout';
import LoginPage from './components/auth/LoginPage';
import { WorkspaceProvider } from './contexts/WorkspaceContext';
import { ModalProvider } from './contexts/ModalContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import './App.scss';
function AppContent() {
return <Layout />;
function AuthenticatedContent() {
const { user, loading, initialized } = useAuth();
if (!initialized) {
return null;
}
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return <LoginPage />;
}
return (
<WorkspaceProvider>
<ModalProvider>
<Layout />
</ModalProvider>
</WorkspaceProvider>
);
}
function App() {
@@ -20,11 +42,9 @@ function App() {
<MantineProvider defaultColorScheme="light">
<Notifications />
<ModalsProvider>
<WorkspaceProvider>
<ModalProvider>
<AppContent />
</ModalProvider>
</WorkspaceProvider>
<AuthProvider>
<AuthenticatedContent />
</AuthProvider>
</ModalsProvider>
</MantineProvider>
</>

View File

@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import {
TextInput,
PasswordInput,
Paper,
Title,
Container,
Button,
Text,
Stack,
} from '@mantine/core';
import { useAuth } from '../../contexts/AuthContext';
const LoginPage = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
} finally {
setLoading(false);
}
};
return (
<Container size={420} my={40}>
<Title ta="center">Welcome to NovaMD</Title>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Please sign in to continue
</Text>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
label="Email"
placeholder="your@email.com"
required
value={email}
onChange={(event) => setEmail(event.currentTarget.value)}
/>
<PasswordInput
label="Password"
placeholder="Your password"
required
value={password}
onChange={(event) => setPassword(event.currentTarget.value)}
/>
<Button type="submit" loading={loading}>
Sign in
</Button>
</Stack>
</form>
</Paper>
</Container>
);
};
export default LoginPage;

View File

@@ -2,8 +2,8 @@ import React from 'react';
import { Text, Center } from '@mantine/core';
import Editor from './Editor';
import MarkdownPreview from './MarkdownPreview';
import { getFileUrl } from '../services/api';
import { isImageFile } from '../utils/fileHelpers';
import { getFileUrl } from '../../services/api';
import { isImageFile } from '../../utils/fileHelpers';
const ContentView = ({
activeTab,
@@ -11,7 +11,7 @@ const ContentView = ({
content,
handleContentChange,
handleSave,
handleLinkClick,
handleFileSelect,
}) => {
if (!selectedFile) {
return (
@@ -47,7 +47,7 @@ const ContentView = ({
selectedFile={selectedFile}
/>
) : (
<MarkdownPreview content={content} handleLinkClick={handleLinkClick} />
<MarkdownPreview content={content} handleFileSelect={handleFileSelect} />
);
};

View File

@@ -5,7 +5,7 @@ import { EditorView, keymap } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown';
import { defaultKeymap } from '@codemirror/commands';
import { oneDark } from '@codemirror/theme-one-dark';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
const { colorScheme } = useWorkspace();

View File

@@ -0,0 +1,111 @@
import React, { useState, useEffect, useMemo } from 'react';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkMath from 'remark-math';
import remarkRehype from 'remark-rehype';
import rehypeMathjax from 'rehype-mathjax';
import rehypeReact from 'rehype-react';
import rehypePrism from 'rehype-prism';
import * as prod from 'react/jsx-runtime';
import { notifications } from '@mantine/notifications';
import { remarkWikiLinks } from '../../utils/remarkWikiLinks';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const MarkdownPreview = ({ content, handleFileSelect }) => {
const [processedContent, setProcessedContent] = useState(null);
const baseUrl = window.API_BASE_URL;
const { currentWorkspace } = useWorkspace();
const handleLinkClick = (e, href) => {
e.preventDefault();
if (href.startsWith(`${baseUrl}/internal/`)) {
// For existing files, extract the path and directly select it
const [filePath] = decodeURIComponent(
href.replace(`${baseUrl}/internal/`, '')
).split('#');
handleFileSelect(filePath);
} else if (href.startsWith(`${baseUrl}/notfound/`)) {
// For non-existent files, show a notification
const fileName = decodeURIComponent(
href.replace(`${baseUrl}/notfound/`, '')
);
notifications.show({
title: 'File Not Found',
message: `The file "${fileName}" does not exist.`,
color: 'red',
});
}
};
const processor = useMemo(
() =>
unified()
.use(remarkParse)
.use(remarkWikiLinks, currentWorkspace?.name)
.use(remarkMath)
.use(remarkRehype)
.use(rehypeMathjax)
.use(rehypePrism)
.use(rehypeReact, {
production: true,
jsx: prod.jsx,
jsxs: prod.jsxs,
Fragment: prod.Fragment,
components: {
img: ({ src, alt, ...props }) => (
<img
src={src}
alt={alt}
onError={(event) => {
console.error('Failed to load image:', event.target.src);
event.target.alt = 'Failed to load image';
}}
{...props}
/>
),
a: ({ href, children, ...props }) => (
<a
href={href}
onClick={(e) => handleLinkClick(e, href)}
{...props}
>
{children}
</a>
),
code: ({ children, className, ...props }) => {
const language = className
? className.replace('language-', '')
: null;
return (
<pre className={className}>
<code {...props}>{children}</code>
</pre>
);
},
},
}),
[baseUrl, handleFileSelect, currentWorkspace?.name]
);
useEffect(() => {
const processContent = async () => {
if (!currentWorkspace) {
return;
}
try {
const result = await processor.process(content);
setProcessedContent(result.result);
} catch (error) {
console.error('Error processing markdown:', error);
}
};
processContent();
}, [content, processor, currentWorkspace]);
return <div className="markdown-preview">{processedContent}</div>;
};
export default MarkdownPreview;

View File

@@ -6,8 +6,8 @@ import {
IconGitPullRequest,
IconGitCommit,
} from '@tabler/icons-react';
import { useModalContext } from '../contexts/ModalContext';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useModalContext } from '../../contexts/ModalContext';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const FileActions = ({ handlePullChanges, selectedFile }) => {
const { settings } = useWorkspace();

View File

@@ -65,10 +65,17 @@ const Node = ({ node, style, dragHandle }) => {
);
};
const FileTree = ({ files, handleFileSelect }) => {
const FileTree = ({ files, handleFileSelect, showHiddenFiles }) => {
const target = useRef(null);
const size = useSize(target);
files = files.filter((file) => {
if (file.name.startsWith('.') && !showHiddenFiles) {
return false;
}
return true;
});
return (
<div
ref={target}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Group, Text } from '@mantine/core';
import UserMenu from '../navigation/UserMenu';
import WorkspaceSwitcher from '../navigation/WorkspaceSwitcher';
import WorkspaceSettings from '../settings/workspace/WorkspaceSettings';
const Header = () => {
return (
<Group justify="space-between" h={60} px="md">
<Text fw={700} size="lg">
NovaMD
</Text>
<Group>
<WorkspaceSwitcher />
<UserMenu />
</Group>
<WorkspaceSettings />
</Group>
);
};
export default Header;

View File

@@ -3,14 +3,13 @@ import { AppShell, Container, Loader, Center } from '@mantine/core';
import Header from './Header';
import Sidebar from './Sidebar';
import MainContent from './MainContent';
import { useFileNavigation } from '../hooks/useFileNavigation';
import { useFileList } from '../hooks/useFileList';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useFileNavigation } from '../../hooks/useFileNavigation';
import { useFileList } from '../../hooks/useFileList';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const Layout = () => {
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
const { selectedFile, handleFileSelect, handleLinkClick } =
useFileNavigation();
const { selectedFile, handleFileSelect } = useFileNavigation();
const { files, loadFileList } = useFileList();
if (workspaceLoading) {
@@ -49,7 +48,6 @@ const Layout = () => {
<MainContent
selectedFile={selectedFile}
handleFileSelect={handleFileSelect}
handleLinkClick={handleLinkClick}
loadFileList={loadFileList}
/>
</Container>

View File

@@ -2,22 +2,17 @@ import React, { useState, useCallback, useMemo } from 'react';
import { Tabs, Breadcrumbs, Group, Box, Text, Flex } from '@mantine/core';
import { IconCode, IconEye, IconPointFilled } from '@tabler/icons-react';
import ContentView from './ContentView';
import CreateFileModal from './modals/CreateFileModal';
import DeleteFileModal from './modals/DeleteFileModal';
import CommitMessageModal from './modals/CommitMessageModal';
import ContentView from '../editor/ContentView';
import CreateFileModal from '../modals/file/CreateFileModal';
import DeleteFileModal from '../modals/file/DeleteFileModal';
import CommitMessageModal from '../modals/git/CommitMessageModal';
import { useFileContent } from '../hooks/useFileContent';
import { useFileOperations } from '../hooks/useFileOperations';
import { useGitOperations } from '../hooks/useGitOperations';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useFileContent } from '../../hooks/useFileContent';
import { useFileOperations } from '../../hooks/useFileOperations';
import { useGitOperations } from '../../hooks/useGitOperations';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const MainContent = ({
selectedFile,
handleFileSelect,
handleLinkClick,
loadFileList,
}) => {
const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
const [activeTab, setActiveTab] = useState('source');
const { settings } = useWorkspace();
const {
@@ -113,7 +108,7 @@ const MainContent = ({
content={content}
handleContentChange={handleContentChange}
handleSave={handleSaveFile}
handleLinkClick={handleLinkClick}
handleFileSelect={handleFileSelect}
/>
</Box>
<CreateFileModal onCreateFile={handleCreateFile} />

View File

@@ -1,9 +1,9 @@
import React, { useEffect } from 'react';
import { Box } from '@mantine/core';
import FileActions from './FileActions';
import FileTree from './FileTree';
import { useGitOperations } from '../hooks/useGitOperations';
import { useWorkspace } from '../contexts/WorkspaceContext';
import FileActions from '../files/FileActions';
import FileTree from '../files/FileTree';
import { useGitOperations } from '../../hooks/useGitOperations';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
const { settings } = useWorkspace();
@@ -28,7 +28,7 @@ const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
<FileTree
files={files}
handleFileSelect={handleFileSelect}
selectedFile={selectedFile}
showHiddenFiles={settings.showHiddenFiles}
/>
</Box>
);

View File

@@ -0,0 +1,55 @@
import React, { useState } from 'react';
import {
Modal,
Stack,
Text,
PasswordInput,
Group,
Button,
} from '@mantine/core';
const DeleteAccountModal = ({ opened, onClose, onConfirm }) => {
const [password, setPassword] = useState('');
return (
<Modal
opened={opened}
onClose={onClose}
title="Delete Account"
centered
size="sm"
>
<Stack>
<Text c="red" fw={500}>
Warning: This action cannot be undone
</Text>
<Text size="sm">
Please enter your password to confirm account deletion.
</Text>
<PasswordInput
label="Current Password"
placeholder="Enter your current password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
color="red"
onClick={() => {
onConfirm(password);
setPassword('');
}}
>
Delete Account
</Button>
</Group>
</Stack>
</Modal>
);
};
export default DeleteAccountModal;

View File

@@ -0,0 +1,51 @@
import React, { useState } from 'react';
import {
Modal,
Text,
Button,
Group,
Stack,
PasswordInput,
} from '@mantine/core';
const EmailPasswordModal = ({ opened, onClose, onConfirm, email }) => {
const [password, setPassword] = useState('');
return (
<Modal
opened={opened}
onClose={onClose}
title="Confirm Password"
centered
size="sm"
>
<Stack>
<Text size="sm">
Please enter your password to confirm changing your email to: {email}
</Text>
<PasswordInput
label="Current Password"
placeholder="Enter your current password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => {
onConfirm(password);
setPassword('');
}}
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
);
};
export default EmailPasswordModal;

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../contexts/ModalContext';
import { useModalContext } from '../../../contexts/ModalContext';
const CreateFileModal = ({ onCreateFile }) => {
const [fileName, setFileName] = useState('');

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Modal, Text, Button, Group } from '@mantine/core';
import { useModalContext } from '../../contexts/ModalContext';
import { useModalContext } from '../../../contexts/ModalContext';
const DeleteFileModal = ({ onDeleteFile, selectedFile }) => {
const { deleteFileModalVisible, setDeleteFileModalVisible } =

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../contexts/ModalContext';
import { useModalContext } from '../../../contexts/ModalContext';
const CommitMessageModal = ({ onCommitAndPush }) => {
const [message, setMessage] = useState('');

View File

@@ -0,0 +1,76 @@
import React, { useState } from 'react';
import {
Modal,
Stack,
TextInput,
PasswordInput,
Select,
Button,
Group,
} from '@mantine/core';
const CreateUserModal = ({ opened, onClose, onCreateUser, loading }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [role, setRole] = useState('viewer');
const handleSubmit = async () => {
const result = await onCreateUser({ email, password, displayName, role });
if (result.success) {
setEmail('');
setPassword('');
setDisplayName('');
setRole('viewer');
onClose();
}
};
return (
<Modal opened={opened} onClose={onClose} title="Create New User" centered>
<Stack>
<TextInput
label="Email"
required
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
placeholder="user@example.com"
/>
<TextInput
label="Display Name"
value={displayName}
onChange={(e) => setDisplayName(e.currentTarget.value)}
placeholder="John Doe"
/>
<PasswordInput
label="Password"
required
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
placeholder="Enter password"
/>
<Select
label="Role"
required
value={role}
onChange={setRole}
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'editor', label: 'Editor' },
{ value: 'viewer', label: 'Viewer' },
]}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={loading}>
Create User
</Button>
</Group>
</Stack>
</Modal>
);
};
export default CreateUserModal;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
const DeleteUserModal = ({ opened, onClose, onConfirm, user, loading }) => (
<Modal
opened={opened}
onClose={onClose}
title="Delete User"
centered
size="sm"
>
<Stack>
<Text>
Are you sure you want to delete user "{user?.email}"? This action cannot
be undone and all associated data will be permanently deleted.
</Text>
<Group justify="flex-end" mt="xl">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button color="red" onClick={onConfirm} loading={loading}>
Delete User
</Button>
</Group>
</Stack>
</Modal>
);
export default DeleteUserModal;

View File

@@ -0,0 +1,105 @@
import React, { useState, useEffect } from 'react';
import {
Modal,
Stack,
TextInput,
Select,
Button,
Group,
PasswordInput,
Text,
} from '@mantine/core';
const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
const [formData, setFormData] = useState({
email: '',
displayName: '',
role: '',
password: '',
});
useEffect(() => {
if (user) {
setFormData({
email: user.email,
displayName: user.displayName || '',
role: user.role,
password: '',
});
}
}, [user]);
const handleSubmit = async () => {
const updateData = {
...formData,
...(formData.password ? { password: formData.password } : {}),
};
const result = await onEditUser(user.id, updateData);
if (result.success) {
setFormData({
email: '',
displayName: '',
role: '',
password: '',
});
onClose();
}
};
return (
<Modal opened={opened} onClose={onClose} title="Edit User" centered>
<Stack>
<TextInput
label="Email"
required
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.currentTarget.value })
}
placeholder="user@example.com"
/>
<TextInput
label="Display Name"
value={formData.displayName}
onChange={(e) =>
setFormData({ ...formData, displayName: e.currentTarget.value })
}
placeholder="John Doe"
/>
<Select
label="Role"
required
value={formData.role}
onChange={(value) => setFormData({ ...formData, role: value })}
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'editor', label: 'Editor' },
{ value: 'viewer', label: 'Viewer' },
]}
/>
<PasswordInput
label="New Password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.currentTarget.value })
}
placeholder="Enter new password (leave empty to keep current)"
/>
<Text size="xs" c="dimmed">
Leave password empty to keep the current password
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={loading}>
Save Changes
</Button>
</Group>
</Stack>
</Modal>
);
};
export default EditUserModal;

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../contexts/ModalContext';
import { createWorkspace } from '../../services/api';
import { useModalContext } from '../../../contexts/ModalContext';
import { createWorkspace } from '../../../services/api';
import { notifications } from '@mantine/notifications';
const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {

View File

@@ -0,0 +1,155 @@
import React, { useState } from 'react';
import {
Avatar,
Popover,
Stack,
UnstyledButton,
Group,
Text,
Divider,
} from '@mantine/core';
import {
IconUser,
IconUsers,
IconLogout,
IconSettings,
} from '@tabler/icons-react';
import { useAuth } from '../../contexts/AuthContext';
import AccountSettings from '../settings/account/AccountSettings';
import AdminDashboard from '../settings/admin/AdminDashboard';
const UserMenu = () => {
const [accountSettingsOpened, setAccountSettingsOpened] = useState(false);
const [adminDashboardOpened, setAdminDashboardOpened] = useState(false);
const [opened, setOpened] = useState(false);
const { user, logout } = useAuth();
const handleLogout = () => {
logout();
};
return (
<>
<Popover
width={200}
position="bottom-end"
withArrow
shadow="md"
opened={opened}
onChange={setOpened}
>
<Popover.Target>
<Avatar
radius="xl"
style={{ cursor: 'pointer' }}
onClick={() => setOpened((o) => !o)}
>
<IconUser size={24} />
</Avatar>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
{/* User Info Section */}
<Group gap="sm">
<Avatar radius="xl" size="md">
<IconUser size={24} />
</Avatar>
<div>
<Text size="sm" fw={500}>
{user.displayName || user.email}
</Text>
</div>
</Group>
<Divider />
{/* Menu Items */}
<UnstyledButton
onClick={() => {
setAccountSettingsOpened(true);
setOpened(false);
}}
px="sm"
py="xs"
style={(theme) => ({
borderRadius: theme.radius.sm,
'&:hover': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0],
},
})}
>
<Group>
<IconSettings size={16} />
<Text size="sm">Account Settings</Text>
</Group>
</UnstyledButton>
{user.role === 'admin' && (
<UnstyledButton
onClick={() => {
setAdminDashboardOpened(true);
setOpened(false);
}}
px="sm"
py="xs"
style={(theme) => ({
borderRadius: theme.radius.sm,
'&:hover': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0],
},
})}
>
<Group>
<IconUsers size={16} />
<Text size="sm">Admin Dashboard</Text>
</Group>
</UnstyledButton>
)}
<UnstyledButton
onClick={handleLogout}
px="sm"
py="xs"
color="red"
style={(theme) => ({
borderRadius: theme.radius.sm,
'&:hover': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0],
},
})}
>
<Group>
<IconLogout size={16} color="red" />
<Text size="sm" c="red">
Logout
</Text>
</Group>
</UnstyledButton>
</Stack>
</Popover.Dropdown>
</Popover>
<AccountSettings
opened={accountSettingsOpened}
onClose={() => setAccountSettingsOpened(false)}
/>
<AdminDashboard
opened={adminDashboardOpened}
onClose={() => setAdminDashboardOpened(false)}
/>
</>
);
};
export default UserMenu;

View File

@@ -15,10 +15,10 @@ import {
useMantineTheme,
} from '@mantine/core';
import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useModalContext } from '../contexts/ModalContext';
import { listWorkspaces } from '../services/api';
import CreateWorkspaceModal from './modals/CreateWorkspaceModal';
import { useWorkspace } from '../../contexts/WorkspaceContext';
import { useModalContext } from '../../contexts/ModalContext';
import { listWorkspaces } from '../../services/api';
import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal';
const WorkspaceSwitcher = () => {
const { currentWorkspace, switchWorkspace } = useWorkspace();
@@ -47,7 +47,7 @@ const WorkspaceSwitcher = () => {
const handleWorkspaceCreated = async (newWorkspace) => {
await loadWorkspaces();
switchWorkspace(newWorkspace.id);
switchWorkspace(newWorkspace.name);
};
return (
@@ -102,10 +102,10 @@ const WorkspaceSwitcher = () => {
</Center>
) : (
workspaces.map((workspace) => {
const isSelected = workspace.id === currentWorkspace?.id;
const isSelected = workspace.name === currentWorkspace?.name;
return (
<Paper
key={workspace.id}
key={workspace.name}
p="xs"
withBorder
style={{
@@ -125,7 +125,7 @@ const WorkspaceSwitcher = () => {
<UnstyledButton
style={{ flex: 1 }}
onClick={() => {
switchWorkspace(workspace.id);
switchWorkspace(workspace.name);
setPopoverOpened(false);
}}
>

View File

@@ -0,0 +1,10 @@
import React from 'react';
import { Accordion, Title } from '@mantine/core';
const AccordionControl = ({ children }) => (
<Accordion.Control>
<Title order={4}>{children}</Title>
</Accordion.Control>
);
export default AccordionControl;

View File

@@ -0,0 +1,248 @@
import React, { useState, useReducer, useRef, useEffect } from 'react';
import {
Modal,
Badge,
Button,
Group,
Title,
Stack,
Accordion,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useAuth } from '../../../contexts/AuthContext';
import { useProfileSettings } from '../../../hooks/useProfileSettings';
import EmailPasswordModal from '../../modals/account/EmailPasswordModal';
import SecuritySettings from './SecuritySettings';
import ProfileSettings from './ProfileSettings';
import DangerZoneSettings from './DangerZoneSettings';
import AccordionControl from '../AccordionControl';
// Reducer for managing settings state
const initialState = {
localSettings: {},
initialSettings: {},
hasUnsavedChanges: false,
};
function settingsReducer(state, action) {
switch (action.type) {
case 'INIT_SETTINGS':
return {
...state,
localSettings: action.payload,
initialSettings: action.payload,
hasUnsavedChanges: false,
};
case 'UPDATE_LOCAL_SETTINGS':
const newLocalSettings = { ...state.localSettings, ...action.payload };
const hasChanges =
JSON.stringify(newLocalSettings) !==
JSON.stringify(state.initialSettings);
return {
...state,
localSettings: newLocalSettings,
hasUnsavedChanges: hasChanges,
};
case 'MARK_SAVED':
return {
...state,
initialSettings: state.localSettings,
hasUnsavedChanges: false,
};
default:
return state;
}
}
const AccountSettings = ({ opened, onClose }) => {
const { user, refreshUser } = useAuth();
const { loading, updateProfile } = useProfileSettings();
const [state, dispatch] = useReducer(settingsReducer, initialState);
const isInitialMount = useRef(true);
const [emailModalOpened, setEmailModalOpened] = useState(false);
// Initialize settings on mount
useEffect(() => {
if (isInitialMount.current && user) {
isInitialMount.current = false;
const settings = {
displayName: user.displayName,
email: user.email,
currentPassword: '',
newPassword: '',
};
dispatch({ type: 'INIT_SETTINGS', payload: settings });
}
}, [user]);
const handleInputChange = (key, value) => {
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
};
const handleSubmit = async () => {
const updates = {};
const needsPasswordConfirmation =
state.localSettings.email !== state.initialSettings.email;
// Add display name if changed
if (state.localSettings.displayName !== state.initialSettings.displayName) {
updates.displayName = state.localSettings.displayName;
}
// Handle password change
if (state.localSettings.newPassword) {
if (!state.localSettings.currentPassword) {
notifications.show({
title: 'Error',
message: 'Current password is required to change password',
color: 'red',
});
return;
}
updates.newPassword = state.localSettings.newPassword;
updates.currentPassword = state.localSettings.currentPassword;
}
// If we're only changing display name or have password already provided, proceed directly
if (!needsPasswordConfirmation || state.localSettings.currentPassword) {
if (needsPasswordConfirmation) {
updates.email = state.localSettings.email;
// If we don't have a password change, we still need to include the current password for email change
if (!updates.currentPassword) {
updates.currentPassword = state.localSettings.currentPassword;
}
}
const result = await updateProfile(updates);
if (result.success) {
await refreshUser();
dispatch({ type: 'MARK_SAVED' });
onClose();
}
} else {
// Only show the email confirmation modal if we don't already have the password
setEmailModalOpened(true);
}
};
const handleEmailConfirm = async (password) => {
const updates = {
...state.localSettings,
currentPassword: password,
};
// Remove any undefined/empty values
Object.keys(updates).forEach((key) => {
if (updates[key] === undefined || updates[key] === '') {
delete updates[key];
}
});
// Remove keys that haven't changed
if (updates.displayName === state.initialSettings.displayName) {
delete updates.displayName;
}
if (updates.email === state.initialSettings.email) {
delete updates.email;
}
const result = await updateProfile(updates);
if (result.success) {
await refreshUser();
dispatch({ type: 'MARK_SAVED' });
setEmailModalOpened(false);
onClose();
}
};
return (
<>
<Modal
opened={opened}
onClose={onClose}
title={<Title order={2}>Account Settings</Title>}
centered
size="lg"
>
<Stack spacing="xl">
{state.hasUnsavedChanges && (
<Badge color="yellow" variant="light">
Unsaved Changes
</Badge>
)}
<Accordion
defaultValue={['profile', 'security', 'danger']}
multiple
styles={(theme) => ({
control: {
paddingTop: theme.spacing.md,
paddingBottom: theme.spacing.md,
},
item: {
borderBottom: `1px solid ${
theme.colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[3]
}`,
'&[data-active]': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[7]
: theme.colors.gray[0],
},
},
})}
>
<Accordion.Item value="profile">
<AccordionControl>Profile</AccordionControl>
<Accordion.Panel>
<ProfileSettings
settings={state.localSettings}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="security">
<AccordionControl>Security</AccordionControl>
<Accordion.Panel>
<SecuritySettings
settings={state.localSettings}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="danger">
<AccordionControl>Danger Zone</AccordionControl>
<Accordion.Panel>
<DangerZoneSettings />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleSubmit}
loading={loading}
disabled={!state.hasUnsavedChanges}
>
Save Changes
</Button>
</Group>
</Stack>
</Modal>
<EmailPasswordModal
opened={emailModalOpened}
onClose={() => setEmailModalOpened(false)}
onConfirm={handleEmailConfirm}
email={state.localSettings.email}
/>
</>
);
};
export default AccountSettings;

View File

@@ -0,0 +1,43 @@
import React, { useState } from 'react';
import { Box, Button, Text } from '@mantine/core';
import DeleteAccountModal from '../../modals/account/DeleteAccountModal';
import { useAuth } from '../../../contexts/AuthContext';
import { useProfileSettings } from '../../../hooks/useProfileSettings';
const DangerZoneSettings = () => {
const { logout } = useAuth();
const { deleteAccount } = useProfileSettings();
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
const handleDelete = async (password) => {
const result = await deleteAccount(password);
if (result.success) {
setDeleteModalOpened(false);
logout();
}
};
return (
<Box mb="md">
<Text size="sm" mb="sm" c="dimmed">
Once you delete your account, there is no going back. Please be certain.
</Text>
<Button
color="red"
variant="light"
onClick={() => setDeleteModalOpened(true)}
fullWidth
>
Delete Account
</Button>
<DeleteAccountModal
opened={deleteModalOpened}
onClose={() => setDeleteModalOpened(false)}
onConfirm={handleDelete}
/>
</Box>
);
};
export default DangerZoneSettings;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Box, Stack, TextInput } from '@mantine/core';
const ProfileSettings = ({ settings, onInputChange }) => (
<Box>
<Stack spacing="md">
<TextInput
label="Display Name"
value={settings.displayName || ''}
onChange={(e) => onInputChange('displayName', e.currentTarget.value)}
placeholder="Enter display name"
/>
<TextInput
label="Email"
value={settings.email || ''}
onChange={(e) => onInputChange('email', e.currentTarget.value)}
placeholder="Enter email"
/>
</Stack>
</Box>
);
export default ProfileSettings;

View File

@@ -0,0 +1,65 @@
import React, { useState } from 'react';
import { Box, PasswordInput, Stack, Text } from '@mantine/core';
const SecuritySettings = ({ settings, onInputChange }) => {
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const handlePasswordChange = (field, value) => {
if (field === 'confirmNewPassword') {
setConfirmPassword(value);
// Check if passwords match when either password field changes
if (value !== settings.newPassword) {
setError('Passwords do not match');
} else {
setError('');
}
} else {
onInputChange(field, value);
// Check if passwords match when either password field changes
if (field === 'newPassword' && value !== confirmPassword) {
setError('Passwords do not match');
} else if (value === confirmPassword) {
setError('');
}
}
};
return (
<Box>
<Stack spacing="md">
<PasswordInput
label="Current Password"
value={settings.currentPassword || ''}
onChange={(e) =>
handlePasswordChange('currentPassword', e.currentTarget.value)
}
placeholder="Enter current password"
/>
<PasswordInput
label="New Password"
value={settings.newPassword || ''}
onChange={(e) =>
handlePasswordChange('newPassword', e.currentTarget.value)
}
placeholder="Enter new password"
/>
<PasswordInput
label="Confirm New Password"
value={confirmPassword}
onChange={(e) =>
handlePasswordChange('confirmNewPassword', e.currentTarget.value)
}
placeholder="Confirm new password"
error={error}
/>
<Text size="xs" c="dimmed">
Password must be at least 8 characters long. Leave password fields
empty if you don't want to change it.
</Text>
</Stack>
</Box>
);
};
export default SecuritySettings;

View File

@@ -0,0 +1,44 @@
import React, { useState } from 'react';
import { Modal, Tabs } from '@mantine/core';
import { IconUsers, IconFolders, IconChartBar } from '@tabler/icons-react';
import { useAuth } from '../../../contexts/AuthContext';
import AdminUsersTab from './AdminUsersTab';
import AdminWorkspacesTab from './AdminWorkspacesTab';
import AdminStatsTab from './AdminStatsTab';
const AdminDashboard = ({ opened, onClose }) => {
const { user: currentUser } = useAuth();
const [activeTab, setActiveTab] = useState('users');
return (
<Modal opened={opened} onClose={onClose} size="xl" title="Admin Dashboard">
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
Users
</Tabs.Tab>
<Tabs.Tab value="workspaces" leftSection={<IconFolders size={16} />}>
Workspaces
</Tabs.Tab>
<Tabs.Tab value="stats" leftSection={<IconChartBar size={16} />}>
Statistics
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="users" pt="md">
<AdminUsersTab currentUser={currentUser} />
</Tabs.Panel>
<Tabs.Panel value="workspaces" pt="md">
<AdminWorkspacesTab />
</Tabs.Panel>
<Tabs.Panel value="stats" pt="md">
<AdminStatsTab />
</Tabs.Panel>
</Tabs>
</Modal>
);
};
export default AdminDashboard;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { Table, Text, Box, LoadingOverlay, Alert } from '@mantine/core';
import { IconAlertCircle } from '@tabler/icons-react';
import { useAdminData } from '../../../hooks/useAdminData';
import { formatBytes } from '../../../utils/formatBytes';
const AdminStatsTab = () => {
const { data: stats, loading, error } = useAdminData('stats');
if (loading) {
return <LoadingOverlay visible={true} />;
}
if (error) {
return (
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
{error}
</Alert>
);
}
const statsRows = [
{ label: 'Total Users', value: stats.totalUsers },
{ label: 'Active Users', value: stats.activeUsers },
{ label: 'Total Workspaces', value: stats.totalWorkspaces },
{ label: 'Total Files', value: stats.totalFiles },
{ label: 'Total Storage Size', value: formatBytes(stats.totalSize) },
];
return (
<Box pos="relative">
<Text size="xl" fw={700} mb="md">
System Statistics
</Text>
<Table striped highlightOnHover withBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Metric</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{statsRows.map((row) => (
<Table.Tr key={row.label}>
<Table.Td>{row.label}</Table.Td>
<Table.Td>{row.value}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
);
};
export default AdminStatsTab;

View File

@@ -0,0 +1,162 @@
import React, { useState } from 'react';
import {
Table,
Button,
Group,
Text,
ActionIcon,
Box,
LoadingOverlay,
Alert,
} from '@mantine/core';
import {
IconTrash,
IconEdit,
IconPlus,
IconAlertCircle,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useUserAdmin } from '../../../hooks/useUserAdmin';
import CreateUserModal from '../../modals/user/CreateUserModal';
import EditUserModal from '../../modals/user/EditUserModal';
import DeleteUserModal from '../../modals/user/DeleteUserModal';
const AdminUsersTab = ({ currentUser }) => {
const {
users,
loading,
error,
create,
update,
delete: deleteUser,
} = useUserAdmin();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [editModalData, setEditModalData] = useState(null);
const [deleteModalData, setDeleteModalData] = useState(null);
const handleCreateUser = async (userData) => {
return await create(userData);
};
const handleEditUser = async (id, userData) => {
return await update(id, userData);
};
const handleDeleteClick = (user) => {
if (user.id === currentUser.id) {
notifications.show({
title: 'Error',
message: 'You cannot delete your own account',
color: 'red',
});
return;
}
setDeleteModalData(user);
};
const handleDeleteConfirm = async () => {
if (!deleteModalData) return;
const result = await deleteUser(deleteModalData.id);
if (result.success) {
setDeleteModalData(null);
}
};
const rows = users.map((user) => (
<Table.Tr key={user.id}>
<Table.Td>{user.email}</Table.Td>
<Table.Td>{user.displayName}</Table.Td>
<Table.Td>
<Text transform="capitalize">{user.role}</Text>
</Table.Td>
<Table.Td>{new Date(user.createdAt).toLocaleDateString()}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
<ActionIcon
variant="subtle"
color="blue"
onClick={() => setEditModalData(user)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDeleteClick(user)}
disabled={user.id === currentUser.id}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Box pos="relative">
<LoadingOverlay visible={loading} />
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
title="Error"
color="red"
mb="md"
>
{error}
</Alert>
)}
<Group justify="space-between" mb="md">
<Text size="xl" fw={700}>
User Management
</Text>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => setCreateModalOpened(true)}
>
Create User
</Button>
</Group>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Email</Table.Th>
<Table.Th>Display Name</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th style={{ width: 100 }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
<CreateUserModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onCreateUser={handleCreateUser}
loading={loading}
/>
<EditUserModal
opened={!!editModalData}
onClose={() => setEditModalData(null)}
onEditUser={handleEditUser}
user={editModalData}
loading={loading}
/>
<DeleteUserModal
opened={!!deleteModalData}
onClose={() => setDeleteModalData(null)}
onConfirm={handleDeleteConfirm}
user={deleteModalData}
loading={loading}
/>
</Box>
);
};
export default AdminUsersTab;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import {
Table,
Group,
Text,
ActionIcon,
Box,
LoadingOverlay,
Alert,
} from '@mantine/core';
import { IconTrash, IconEdit, IconAlertCircle } from '@tabler/icons-react';
import { useAdminData } from '../../../hooks/useAdminData';
import { formatBytes } from '../../../utils/formatBytes';
const AdminWorkspacesTab = () => {
const { data: workspaces, loading, error } = useAdminData('workspaces');
const rows = workspaces.map((workspace) => (
<Table.Tr key={workspace.id}>
<Table.Td>{workspace.userEmail}</Table.Td>
<Table.Td>{workspace.workspaceName}</Table.Td>
<Table.Td>
{new Date(workspace.workspaceCreatedAt).toLocaleDateString()}
</Table.Td>
<Table.Td>{workspace.totalFiles}</Table.Td>
<Table.Td>{formatBytes(workspace.totalSize)}</Table.Td>
</Table.Tr>
));
return (
<Box pos="relative">
<LoadingOverlay visible={loading} />
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
title="Error"
color="red"
mb="md"
>
{error}
</Alert>
)}
<Group justify="space-between" mb="md">
<Text size="xl" fw={700}>
Workspace Management
</Text>
</Group>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Owner</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Total Files</Table.Th>
<Table.Th>Total Size</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Box>
);
};
export default AdminWorkspacesTab;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Text, Switch, Group, Box, Title } from '@mantine/core';
import { useWorkspace } from '../../contexts/WorkspaceContext';
import { useWorkspace } from '../../../contexts/WorkspaceContext';
const AppearanceSettings = ({ themeSettings, onThemeChange }) => {
const { colorScheme, updateColorScheme } = useWorkspace();

View File

@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { Box, Button, Title } from '@mantine/core';
import DeleteWorkspaceModal from '../modals/DeleteWorkspaceModal';
import { useWorkspace } from '../../contexts/WorkspaceContext';
import { useModalContext } from '../../contexts/ModalContext';
import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal';
import { useWorkspace } from '../../../contexts/WorkspaceContext';
import { useModalContext } from '../../../contexts/ModalContext';
const DangerZoneSettings = () => {
const { currentWorkspace, workspaces, deleteCurrentWorkspace } =

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { Text, Switch, Tooltip, Group, Box } from '@mantine/core';
const EditorSettings = ({
autoSave,
showHiddenFiles,
onAutoSaveChange,
onShowHiddenFilesChange,
}) => {
return (
<Box mb="md">
<Tooltip label="Auto Save feature is coming soon!" position="left">
<Group justify="space-between" align="center" mb="sm">
<Text size="sm">Auto Save</Text>
<Switch
checked={autoSave}
onChange={(event) => onAutoSaveChange(event.currentTarget.checked)}
disabled
/>
</Group>
</Tooltip>
<Group justify="space-between" align="center">
<Text size="sm">Show Hidden Files</Text>
<Switch
checked={showHiddenFiles}
onChange={(event) =>
onShowHiddenFilesChange(event.currentTarget.checked)
}
/>
</Group>
</Box>
);
};
export default EditorSettings;

View File

@@ -6,7 +6,6 @@ import {
Stack,
PasswordInput,
Group,
Title,
Grid,
} from '@mantine/core';
@@ -17,13 +16,15 @@ const GitSettings = ({
gitToken,
gitAutoCommit,
gitCommitMsgTemplate,
gitCommitName,
gitCommitEmail,
onInputChange,
}) => {
return (
<Stack spacing="md">
<Grid gutter="md" align="center">
<Grid.Col span={6}>
<Text size="sm">Enable Git</Text>
<Text size="sm">Enable Git Repository</Text>
</Grid.Col>
<Grid.Col span={6}>
<Group justify="flex-end">
@@ -42,6 +43,7 @@ const GitSettings = ({
<Grid.Col span={6}>
<TextInput
value={gitUrl}
description="The URL of your Git repository"
onChange={(event) =>
onInputChange('gitUrl', event.currentTarget.value)
}
@@ -51,11 +53,12 @@ const GitSettings = ({
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Git Username</Text>
<Text size="sm">Username</Text>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
value={gitUser}
description="The username used to authenticate with the repository"
onChange={(event) =>
onInputChange('gitUser', event.currentTarget.value)
}
@@ -65,11 +68,12 @@ const GitSettings = ({
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Git Token</Text>
<Text size="sm">Access Token</Text>
</Grid.Col>
<Grid.Col span={6}>
<PasswordInput
value={gitToken}
description="Personal access token with repository read/write permissions"
onChange={(event) =>
onInputChange('gitToken', event.currentTarget.value)
}
@@ -79,7 +83,7 @@ const GitSettings = ({
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Auto Commit</Text>
<Text size="sm">Commit on Save</Text>
</Grid.Col>
<Grid.Col span={6}>
<Group justify="flex-end">
@@ -99,6 +103,7 @@ const GitSettings = ({
<Grid.Col span={6}>
<TextInput
value={gitCommitMsgTemplate}
description="Template for automated commit messages. Use ${filename} and ${action} as a placeholder."
onChange={(event) =>
onInputChange('gitCommitMsgTemplate', event.currentTarget.value)
}
@@ -106,6 +111,36 @@ const GitSettings = ({
placeholder="Enter commit message template"
/>
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Commit Author</Text>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
value={gitCommitName}
description="Name to appear in commit history. Leave empty to use Git username."
onChange={(event) =>
onInputChange('gitCommitName', event.currentTarget.value)
}
disabled={!gitEnabled}
placeholder="Enter commit author name."
/>
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Commit Author Email</Text>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
value={gitCommitEmail}
description="Email address to associate with your commits"
onChange={(event) =>
onInputChange('gitCommitEmail', event.currentTarget.value)
}
disabled={!gitEnabled}
placeholder="Enter commit author email."
/>
</Grid.Col>
</Grid>
</Stack>
);

View File

@@ -9,13 +9,14 @@ import {
Accordion,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useWorkspace } from '../contexts/WorkspaceContext';
import AppearanceSettings from './settings/AppearanceSettings';
import EditorSettings from './settings/EditorSettings';
import GitSettings from './settings/GitSettings';
import GeneralSettings from './settings/GeneralSettings';
import { useModalContext } from '../contexts/ModalContext';
import DangerZoneSettings from './settings/DangerZoneSettings';
import { useWorkspace } from '../../../contexts/WorkspaceContext';
import AppearanceSettings from './AppearanceSettings';
import EditorSettings from './EditorSettings';
import GitSettings from './GitSettings';
import GeneralSettings from './GeneralSettings';
import { useModalContext } from '../../../contexts/ModalContext';
import DangerZoneSettings from './DangerZoneSettings';
import AccordionControl from '../AccordionControl';
const initialState = {
localSettings: {},
@@ -53,13 +54,7 @@ function settingsReducer(state, action) {
}
}
const AccordionControl = ({ children }) => (
<Accordion.Control>
<Title order={4}>{children}</Title>
</Accordion.Control>
);
const Settings = () => {
const WorkspaceSettings = () => {
const { currentWorkspace, updateSettings } = useWorkspace();
const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
const [state, dispatch] = useReducer(settingsReducer, initialState);
@@ -72,12 +67,15 @@ const Settings = () => {
name: currentWorkspace.name,
theme: currentWorkspace.theme,
autoSave: currentWorkspace.autoSave,
showHiddenFiles: currentWorkspace.showHiddenFiles,
gitEnabled: currentWorkspace.gitEnabled,
gitUrl: currentWorkspace.gitUrl,
gitUser: currentWorkspace.gitUser,
gitToken: currentWorkspace.gitToken,
gitAutoCommit: currentWorkspace.gitAutoCommit,
gitCommitMsgTemplate: currentWorkspace.gitCommitMsgTemplate,
gitCommitName: currentWorkspace.gitCommitName,
gitCommitEmail: currentWorkspace.gitCommitEmail,
};
dispatch({ type: 'INIT_SETTINGS', payload: settings });
}
@@ -121,7 +119,7 @@ const Settings = () => {
<Modal
opened={settingsModalVisible}
onClose={handleClose}
title={<Title order={2}>Settings</Title>}
title={<Title order={2}>Workspace Settings</Title>}
centered
size="lg"
>
@@ -190,6 +188,10 @@ const Settings = () => {
onAutoSaveChange={(value) =>
handleInputChange('autoSave', value)
}
showHiddenFiles={state.localSettings.showHiddenFiles}
onShowHiddenFilesChange={(value) =>
handleInputChange('showHiddenFiles', value)
}
/>
</Accordion.Panel>
</Accordion.Item>
@@ -204,6 +206,8 @@ const Settings = () => {
gitToken={state.localSettings.gitToken}
gitAutoCommit={state.localSettings.gitAutoCommit}
gitCommitMsgTemplate={state.localSettings.gitCommitMsgTemplate}
gitCommitName={state.localSettings.gitCommitName}
gitCommitEmail={state.localSettings.gitCommitEmail}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
@@ -228,4 +232,4 @@ const Settings = () => {
);
};
export default Settings;
export default WorkspaceSettings;

View File

@@ -0,0 +1,120 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
} from 'react';
import { notifications } from '@mantine/notifications';
import * as authApi from '../services/authApi';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [initialized, setInitialized] = useState(false);
// Load user data on mount
useEffect(() => {
const initializeAuth = async () => {
try {
const storedToken = localStorage.getItem('accessToken');
if (storedToken) {
authApi.setAuthToken(storedToken);
const userData = await authApi.getCurrentUser();
setUser(userData);
}
} catch (error) {
console.error('Failed to initialize auth:', error);
localStorage.removeItem('accessToken');
authApi.clearAuthToken();
} finally {
setLoading(false);
setInitialized(true);
}
};
initializeAuth();
}, []);
const login = useCallback(async (email, password) => {
try {
const { accessToken, user: userData } = await authApi.login(
email,
password
);
localStorage.setItem('accessToken', accessToken);
authApi.setAuthToken(accessToken);
setUser(userData);
notifications.show({
title: 'Success',
message: 'Logged in successfully',
color: 'green',
});
return true;
} catch (error) {
console.error('Login failed:', error);
notifications.show({
title: 'Error',
message: error.message || 'Login failed',
color: 'red',
});
return false;
}
}, []);
const logout = useCallback(async () => {
try {
await authApi.logout();
} catch (error) {
console.error('Logout failed:', error);
} finally {
localStorage.removeItem('accessToken');
authApi.clearAuthToken();
setUser(null);
}
}, []);
const refreshToken = useCallback(async () => {
try {
const { accessToken } = await authApi.refreshToken();
localStorage.setItem('accessToken', accessToken);
authApi.setAuthToken(accessToken);
return true;
} catch (error) {
console.error('Token refresh failed:', error);
await logout();
return false;
}
}, [logout]);
const refreshUser = useCallback(async () => {
try {
const userData = await authApi.getCurrentUser();
setUser(userData);
} catch (error) {
console.error('Failed to refresh user data:', error);
}
}, []);
const value = {
user,
loading,
initialized,
login,
logout,
refreshToken,
refreshUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -8,10 +8,10 @@ import React, {
import { useMantineColorScheme } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
fetchLastWorkspaceId,
fetchLastWorkspaceName,
getWorkspace,
updateWorkspace,
updateLastWorkspace,
updateLastWorkspaceName,
deleteWorkspace,
listWorkspaces,
} from '../services/api';
@@ -41,9 +41,9 @@ export const WorkspaceProvider = ({ children }) => {
}
}, []);
const loadWorkspaceData = useCallback(async (workspaceId) => {
const loadWorkspaceData = useCallback(async (workspaceName) => {
try {
const workspace = await getWorkspace(workspaceId);
const workspace = await getWorkspace(workspaceName);
setCurrentWorkspace(workspace);
setColorScheme(workspace.theme);
} catch (error) {
@@ -61,8 +61,8 @@ export const WorkspaceProvider = ({ children }) => {
const allWorkspaces = await listWorkspaces();
if (allWorkspaces.length > 0) {
const firstWorkspace = allWorkspaces[0];
await updateLastWorkspace(firstWorkspace.id);
await loadWorkspaceData(firstWorkspace.id);
await updateLastWorkspaceName(firstWorkspace.name);
await loadWorkspaceData(firstWorkspace.name);
}
} catch (error) {
console.error('Failed to load first available workspace:', error);
@@ -77,9 +77,9 @@ export const WorkspaceProvider = ({ children }) => {
useEffect(() => {
const initializeWorkspace = async () => {
try {
const { lastWorkspaceId } = await fetchLastWorkspaceId();
if (lastWorkspaceId) {
await loadWorkspaceData(lastWorkspaceId);
const { lastWorkspaceName } = await fetchLastWorkspaceName();
if (lastWorkspaceName) {
await loadWorkspaceData(lastWorkspaceName);
} else {
await loadFirstAvailableWorkspace();
}
@@ -95,11 +95,11 @@ export const WorkspaceProvider = ({ children }) => {
initializeWorkspace();
}, []);
const switchWorkspace = useCallback(async (workspaceId) => {
const switchWorkspace = useCallback(async (workspaceName) => {
try {
setLoading(true);
await updateLastWorkspace(workspaceId);
await loadWorkspaceData(workspaceId);
await updateLastWorkspaceName(workspaceName);
await loadWorkspaceData(workspaceName);
await loadWorkspaces();
} catch (error) {
console.error('Failed to switch workspace:', error);
@@ -129,10 +129,10 @@ export const WorkspaceProvider = ({ children }) => {
}
// Delete workspace and get the next workspace ID
const response = await deleteWorkspace(currentWorkspace.id);
const response = await deleteWorkspace(currentWorkspace.name);
// Load the new workspace data
await loadWorkspaceData(response.nextWorkspaceId);
await loadWorkspaceData(response.nextWorkspaceName);
notifications.show({
title: 'Success',
@@ -162,7 +162,7 @@ export const WorkspaceProvider = ({ children }) => {
};
const response = await updateWorkspace(
currentWorkspace.id,
currentWorkspace.name,
updatedWorkspace
);
setCurrentWorkspace(response);

View File

@@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
import { notifications } from '@mantine/notifications';
import { getUsers, getWorkspaces, getSystemStats } from '../services/adminApi';
// Hook for admin data fetching (stats and workspaces)
export const useAdminData = (type) => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const loadData = async () => {
setLoading(true);
setError(null);
try {
let response;
switch (type) {
case 'stats':
response = await getSystemStats();
break;
case 'workspaces':
response = await getWorkspaces();
break;
case 'users':
response = await getUsers();
break;
default:
throw new Error('Invalid data type');
}
setData(response);
} catch (err) {
const message = err.response?.data?.error || err.message;
setError(message);
notifications.show({
title: 'Error',
message: `Failed to load ${type}: ${message}`,
color: 'red',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, [type]);
return { data, loading, error, reload: loadData };
};

View File

@@ -19,7 +19,7 @@ export const useFileContent = (selectedFile) => {
if (filePath === DEFAULT_FILE.path) {
newContent = DEFAULT_FILE.content;
} else if (!isImageFile(filePath)) {
newContent = await fetchFileContent(currentWorkspace.id, filePath);
newContent = await fetchFileContent(currentWorkspace.name, filePath);
} else {
newContent = ''; // Set empty content for image files
}

View File

@@ -10,7 +10,7 @@ export const useFileList = () => {
if (!currentWorkspace || workspaceLoading) return;
try {
const fileList = await fetchFileList(currentWorkspace.id);
const fileList = await fetchFileList(currentWorkspace.name);
if (Array.isArray(fileList)) {
setFiles(fileList);
} else {

View File

@@ -1,6 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { notifications } from '@mantine/notifications';
import { lookupFileByName } from '../services/api';
import { DEFAULT_FILE } from '../utils/constants';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useLastOpenedFile } from './useLastOpenedFile';
@@ -24,36 +22,12 @@ export const useFileNavigation = () => {
[saveLastOpenedFile]
);
const handleLinkClick = useCallback(
async (filename) => {
if (!currentWorkspace) return;
try {
const filePaths = await lookupFileByName(currentWorkspace.id, filename);
if (filePaths.length >= 1) {
handleFileSelect(filePaths[0]);
} else {
notifications.show({
title: 'File Not Found',
message: `File "${filename}" not found`,
color: 'red',
});
}
} catch (error) {
console.error('Error looking up file:', error);
notifications.show({
title: 'Error',
message: 'Failed to lookup file.',
color: 'red',
});
}
},
[currentWorkspace, handleFileSelect]
);
// Load last opened file when workspace changes
useEffect(() => {
const initializeFile = async () => {
setSelectedFile(DEFAULT_FILE.path);
setIsNewFile(true);
const lastFile = await loadLastOpenedFile();
if (lastFile) {
handleFileSelect(lastFile);
@@ -62,8 +36,10 @@ export const useFileNavigation = () => {
}
};
initializeFile();
if (currentWorkspace) {
initializeFile();
}
}, [currentWorkspace, loadLastOpenedFile, handleFileSelect]);
return { handleLinkClick, selectedFile, isNewFile, handleFileSelect };
return { selectedFile, isNewFile, handleFileSelect };
};

View File

@@ -29,7 +29,7 @@ export const useFileOperations = () => {
if (!currentWorkspace) return false;
try {
await saveFileContent(currentWorkspace.id, filePath, content);
await saveFileContent(currentWorkspace.name, filePath, content);
notifications.show({
title: 'Success',
message: 'File saved successfully',
@@ -55,7 +55,7 @@ export const useFileOperations = () => {
if (!currentWorkspace) return false;
try {
await deleteFile(currentWorkspace.id, filePath);
await deleteFile(currentWorkspace.name, filePath);
notifications.show({
title: 'Success',
message: 'File deleted successfully',
@@ -81,7 +81,7 @@ export const useFileOperations = () => {
if (!currentWorkspace) return false;
try {
await saveFileContent(currentWorkspace.id, fileName, initialContent);
await saveFileContent(currentWorkspace.name, fileName, initialContent);
notifications.show({
title: 'Success',
message: 'File created successfully',

View File

@@ -10,7 +10,7 @@ export const useGitOperations = () => {
if (!currentWorkspace || !settings.gitEnabled) return false;
try {
await pullChanges(currentWorkspace.id);
await pullChanges(currentWorkspace.name);
notifications.show({
title: 'Success',
message: 'Successfully pulled latest changes',
@@ -33,7 +33,7 @@ export const useGitOperations = () => {
if (!currentWorkspace || !settings.gitEnabled) return false;
try {
await commitAndPush(currentWorkspace.id, message);
await commitAndPush(currentWorkspace.name, message);
notifications.show({
title: 'Success',
message: 'Successfully committed and pushed changes',

View File

@@ -9,7 +9,7 @@ export const useLastOpenedFile = () => {
if (!currentWorkspace) return null;
try {
const response = await getLastOpenedFile(currentWorkspace.id);
const response = await getLastOpenedFile(currentWorkspace.name);
return response.lastOpenedFilePath || null;
} catch (error) {
console.error('Failed to load last opened file:', error);
@@ -22,7 +22,7 @@ export const useLastOpenedFile = () => {
if (!currentWorkspace) return;
try {
await updateLastOpenedFile(currentWorkspace.id, filePath);
await updateLastOpenedFile(currentWorkspace.name, filePath);
} catch (error) {
console.error('Failed to save last opened file:', error);
}

View File

@@ -0,0 +1,71 @@
import { useState, useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { updateProfile, deleteProfile } from '../services/api';
export function useProfileSettings() {
const [loading, setLoading] = useState(false);
const handleProfileUpdate = useCallback(async (updates) => {
setLoading(true);
try {
const updatedUser = await updateProfile(updates);
notifications.show({
title: 'Success',
message: 'Profile updated successfully',
color: 'green',
});
return { success: true, user: updatedUser };
} catch (error) {
let errorMessage = 'Failed to update profile';
if (error.message.includes('password')) {
errorMessage = 'Current password is incorrect';
} else if (error.message.includes('email')) {
errorMessage = 'Email is already in use';
}
notifications.show({
title: 'Error',
message: errorMessage,
color: 'red',
});
return { success: false, error: error.message };
} finally {
setLoading(false);
}
}, []);
const handleAccountDeletion = useCallback(async (password) => {
setLoading(true);
try {
await deleteProfile(password);
notifications.show({
title: 'Success',
message: 'Account deleted successfully',
color: 'green',
});
return { success: true };
} catch (error) {
notifications.show({
title: 'Error',
message: error.message || 'Failed to delete account',
color: 'red',
});
return { success: false, error: error.message };
} finally {
setLoading(false);
}
}, []);
return {
loading,
updateProfile: handleProfileUpdate,
deleteAccount: handleAccountDeletion,
};
}

View File

@@ -0,0 +1,79 @@
import { useAdminData } from './useAdminData';
import { createUser, updateUser, deleteUser } from '../services/adminApi';
import { notifications } from '@mantine/notifications';
export const useUserAdmin = () => {
const { data: users, loading, error, reload } = useAdminData('users');
const handleCreate = async (userData) => {
try {
await createUser(userData);
notifications.show({
title: 'Success',
message: 'User created successfully',
color: 'green',
});
reload();
return { success: true };
} catch (err) {
const message = err.response?.data?.error || err.message;
notifications.show({
title: 'Error',
message: `Failed to create user: ${message}`,
color: 'red',
});
return { success: false, error: message };
}
};
const handleUpdate = async (userId, userData) => {
try {
await updateUser(userId, userData);
notifications.show({
title: 'Success',
message: 'User updated successfully',
color: 'green',
});
reload();
return { success: true };
} catch (err) {
const message = err.response?.data?.error || err.message;
notifications.show({
title: 'Error',
message: `Failed to update user: ${message}`,
color: 'red',
});
return { success: false, error: message };
}
};
const handleDelete = async (userId) => {
try {
await deleteUser(userId);
notifications.show({
title: 'Success',
message: 'User deleted successfully',
color: 'green',
});
reload();
return { success: true };
} catch (err) {
const message = err.response?.data?.error || err.message;
notifications.show({
title: 'Error',
message: `Failed to delete user: ${message}`,
color: 'red',
});
return { success: false, error: message };
}
};
return {
users,
loading,
error,
create: handleCreate,
update: handleUpdate,
delete: handleDelete,
};
};

View File

@@ -0,0 +1,49 @@
import { apiCall } from './authApi';
import { API_BASE_URL } from '../utils/constants';
const ADMIN_BASE_URL = `${API_BASE_URL}/admin`;
// User Management
export const getUsers = async () => {
const response = await apiCall(`${ADMIN_BASE_URL}/users`);
return response.json();
};
export const createUser = async (userData) => {
const response = await apiCall(`${ADMIN_BASE_URL}/users`, {
method: 'POST',
body: JSON.stringify(userData),
});
return response.json();
};
export const deleteUser = async (userId) => {
const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, {
method: 'DELETE',
});
if (response.status === 204) {
return;
} else {
throw new Error('Failed to delete user with status: ', response.status);
}
};
export const updateUser = async (userId, userData) => {
const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(userData),
});
return response.json();
};
// Workspace Management
export const getWorkspaces = async () => {
const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`);
return response.json();
};
// System Statistics
export const getSystemStats = async () => {
const response = await apiCall(`${ADMIN_BASE_URL}/stats`);
return response.json();
};

177
app/src/services/api.js Normal file
View File

@@ -0,0 +1,177 @@
import { API_BASE_URL } from '../utils/constants';
import { apiCall } from './authApi';
export const updateProfile = async (updates) => {
const response = await apiCall(`${API_BASE_URL}/profile`, {
method: 'PUT',
body: JSON.stringify(updates),
});
return response.json();
};
export const deleteProfile = async (password) => {
const response = await apiCall(`${API_BASE_URL}/profile`, {
method: 'DELETE',
body: JSON.stringify({ password }),
});
return response.json();
};
export const fetchLastWorkspaceName = async () => {
const response = await apiCall(`${API_BASE_URL}/workspaces/last`);
return response.json();
};
export const fetchFileList = async (workspaceName) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files`
);
return response.json();
};
export const fetchFileContent = async (workspaceName, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`
);
return response.text();
};
export const saveFileContent = async (workspaceName, filePath, content) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`,
{
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
body: content,
}
);
return response.text();
};
export const deleteFile = async (workspaceName, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`,
{
method: 'DELETE',
}
);
return response.text();
};
export const getWorkspace = async (workspaceName) => {
const response = await apiCall(`${API_BASE_URL}/workspaces/${workspaceName}`);
return response.json();
};
// Combined function to update workspace data including settings
export const updateWorkspace = async (workspaceName, workspaceData) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(workspaceData),
}
);
return response.json();
};
export const pullChanges = async (workspaceName) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/git/pull`,
{
method: 'POST',
}
);
return response.json();
};
export const commitAndPush = async (workspaceName, message) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/git/commit`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message }),
}
);
return response.json();
};
export const getFileUrl = (workspaceName, filePath) => {
return `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`;
};
export const lookupFileByName = async (workspaceName, filename) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/lookup?filename=${encodeURIComponent(
filename
)}`
);
const data = await response.json();
return data.paths;
};
export const updateLastOpenedFile = async (workspaceName, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/last`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filePath }),
}
);
return response.json();
};
export const getLastOpenedFile = async (workspaceName) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}/files/last`
);
return response.json();
};
export const listWorkspaces = async () => {
const response = await apiCall(`${API_BASE_URL}/workspaces`);
return response.json();
};
export const createWorkspace = async (name) => {
const response = await apiCall(`${API_BASE_URL}/workspaces`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
});
return response.json();
};
export const deleteWorkspace = async (workspaceName) => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${workspaceName}`,
{
method: 'DELETE',
}
);
return response.json();
};
export const updateLastWorkspaceName = async (workspaceName) => {
const response = await apiCall(`${API_BASE_URL}/workspaces/last`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ workspaceName }),
});
return response.json();
};

View File

@@ -0,0 +1,97 @@
import { API_BASE_URL } from '../utils/constants';
let authToken = null;
export const setAuthToken = (token) => {
authToken = token;
};
export const clearAuthToken = () => {
authToken = null;
};
export const getAuthHeaders = () => {
const headers = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return headers;
};
// Update the existing apiCall function to include auth headers
export const apiCall = async (url, options = {}) => {
try {
const headers = {
...getAuthHeaders(),
...options.headers,
};
const response = await fetch(url, {
...options,
headers,
});
// Handle 401 responses
if (response.status === 401) {
const isRefreshEndpoint = url.endsWith('/auth/refresh');
if (!isRefreshEndpoint) {
// Attempt token refresh and retry the request
const refreshSuccess = await refreshToken();
if (refreshSuccess) {
// Retry the original request with the new token
return apiCall(url, options);
}
}
throw new Error('Authentication failed');
}
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(
errorData?.message || `HTTP error! status: ${response.status}`
);
}
return response;
} catch (error) {
console.error(`API call failed: ${error.message}`);
throw error;
}
};
// Authentication endpoints
export const login = async (email, password) => {
const response = await apiCall(`${API_BASE_URL}/auth/login`, {
method: 'POST',
body: JSON.stringify({ email, password }),
});
return response.json();
};
export const logout = async () => {
const sessionId = localStorage.getItem('sessionId');
await apiCall(`${API_BASE_URL}/auth/logout`, {
method: 'POST',
headers: {
'X-Session-ID': sessionId,
},
});
};
export const refreshToken = async () => {
const refreshToken = localStorage.getItem('refreshToken');
const response = await apiCall(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
body: JSON.stringify({ refreshToken }),
});
return response.json();
};
export const getCurrentUser = async () => {
const response = await apiCall(`${API_BASE_URL}/auth/me`);
return response.json();
};

View File

@@ -53,3 +53,15 @@ export const DEFAULT_FILE = {
export const MARKDOWN_REGEX = {
WIKILINK: /(!?)\[\[(.*?)\]\]/g,
};
// List of element types that can contain inline content
export const INLINE_CONTAINER_TYPES = new Set([
'paragraph',
'listItem',
'tableCell',
'blockquote',
'heading',
'emphasis',
'strong',
'delete',
]);

View File

@@ -0,0 +1,10 @@
export const formatBytes = (bytes) => {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};

View File

@@ -0,0 +1,177 @@
import { visit } from 'unist-util-visit';
import { lookupFileByName, getFileUrl } from '../services/api';
import { INLINE_CONTAINER_TYPES, MARKDOWN_REGEX } from './constants';
function createNotFoundLink(fileName, displayText, baseUrl) {
return {
type: 'link',
url: `${baseUrl}/notfound/${encodeURIComponent(fileName)}`,
children: [{ type: 'text', value: displayText }],
data: {
hProperties: { style: { color: 'red', textDecoration: 'underline' } },
},
};
}
function createFileLink(filePath, displayText, heading, baseUrl) {
const url = heading
? `${baseUrl}/internal/${encodeURIComponent(filePath)}#${encodeURIComponent(
heading
)}`
: `${baseUrl}/internal/${encodeURIComponent(filePath)}`;
return {
type: 'link',
url,
children: [{ type: 'text', value: displayText }],
};
}
function createImageNode(workspaceName, filePath, displayText) {
return {
type: 'image',
url: getFileUrl(workspaceName, filePath),
alt: displayText,
title: displayText,
};
}
function addMarkdownExtension(fileName) {
if (fileName.includes('.')) {
return fileName;
}
return `${fileName}.md`;
}
export function remarkWikiLinks(workspaceName) {
return async function transformer(tree) {
if (!workspaceName) {
console.warn('No workspace ID provided to remarkWikiLinks plugin');
return;
}
const baseUrl = window.API_BASE_URL;
const replacements = new Map();
// Find all wiki links
visit(tree, 'text', function (node, index, parent) {
const regex = MARKDOWN_REGEX.WIKILINK;
let match;
const matches = [];
while ((match = regex.exec(node.value)) !== null) {
const [fullMatch, isImage, innerContent] = match;
let fileName, displayText, heading;
const pipeIndex = innerContent.indexOf('|');
const hashIndex = innerContent.indexOf('#');
if (pipeIndex !== -1) {
displayText = innerContent.slice(pipeIndex + 1).trim();
fileName = innerContent.slice(0, pipeIndex).trim();
} else {
displayText = innerContent;
fileName = innerContent;
}
if (hashIndex !== -1 && (pipeIndex === -1 || hashIndex < pipeIndex)) {
heading = fileName.slice(hashIndex + 1).trim();
fileName = fileName.slice(0, hashIndex).trim();
}
matches.push({
fullMatch,
isImage,
fileName,
displayText,
heading,
index: match.index,
});
}
if (matches.length > 0) {
replacements.set(node, { matches, parent, index });
}
});
// Process all matches
for (const [node, { matches, parent }] of replacements) {
const newNodes = [];
let lastIndex = 0;
for (const match of matches) {
// Add text before the match
if (match.index > lastIndex) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex, match.index),
});
}
try {
const lookupFileName = match.isImage
? match.fileName
: addMarkdownExtension(match.fileName);
const paths = await lookupFileByName(workspaceName, lookupFileName);
if (paths && paths.length > 0) {
const filePath = paths[0];
if (match.isImage) {
newNodes.push(
createImageNode(workspaceName, filePath, match.displayText)
);
} else {
newNodes.push(
createFileLink(
filePath,
match.displayText,
match.heading,
baseUrl
)
);
}
} else {
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
);
}
} catch (error) {
console.debug('File lookup failed:', match.fileName, error);
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
);
}
lastIndex = match.index + match.fullMatch.length;
}
// Add any remaining text
if (lastIndex < node.value.length) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex),
});
}
// If the parent is a container that can have inline content,
// replace the text node directly with the new nodes
if (parent && INLINE_CONTAINER_TYPES.has(parent.type)) {
const nodeIndex = parent.children.indexOf(node);
if (nodeIndex !== -1) {
parent.children.splice(nodeIndex, 1, ...newNodes);
}
} else {
// For other types of parents, wrap the nodes in a paragraph
const paragraph = {
type: 'paragraph',
children: newNodes,
};
const nodeIndex = parent.children.indexOf(node);
if (nodeIndex !== -1) {
parent.children.splice(nodeIndex, 1, paragraph);
}
}
}
};
}

View File

@@ -52,11 +52,16 @@ export default defineConfig(({ mode }) => ({
// Markdown processing
markdown: [
'react-markdown',
'react-syntax-highlighter',
'rehype-katex',
'rehype-mathjax',
'rehype-prism',
'rehype-react',
'remark',
'remark-math',
'katex',
'remark-parse',
'remark-rehype',
'unified',
'unist-util-visit',
],
// Icons and utilities

View File

@@ -1,69 +0,0 @@
package main
import (
"log"
"net/http"
"os"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"novamd/internal/api"
"novamd/internal/config"
"novamd/internal/db"
"novamd/internal/filesystem"
"novamd/internal/user"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load configuration:", err)
}
// Initialize database
database, err := db.Init(cfg.DBPath, cfg.EncryptionKey)
if err != nil {
log.Fatal(err)
}
defer func() {
if err := database.Close(); err != nil {
log.Printf("Error closing database: %v", err)
}
}()
// Initialize filesystem
fs := filesystem.New(cfg.WorkDir)
// Initialize user service
userService := user.NewUserService(database, fs)
// Create admin user
if _, err := userService.SetupAdminUser(cfg.AdminEmail, cfg.AdminPassword); err != nil {
log.Fatal(err)
}
// Set up router
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// API routes
r.Route("/api/v1", func(r chi.Router) {
api.SetupRoutes(r, database, fs)
})
// Handle all other routes with static file server
r.Get("/*", api.NewStaticHandler(cfg.StaticPath).ServeHTTP)
// Start server
port := os.Getenv("NOVAMD_PORT")
if port == "" {
port = "8080"
}
log.Printf("Server starting on port %s", port)
log.Fatal(http.ListenAndServe(":"+port, r))
}

View File

@@ -1,171 +0,0 @@
package api
import (
"encoding/json"
"io"
"net/http"
"novamd/internal/db"
"novamd/internal/filesystem"
"github.com/go-chi/chi/v5"
)
func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
files, err := fs.ListFilesRecursively(userID, workspaceID)
if err != nil {
http.Error(w, "Failed to list files", http.StatusInternalServerError)
return
}
respondJSON(w, files)
}
}
func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filename := r.URL.Query().Get("filename")
if filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
filePaths, err := fs.FindFileByName(userID, workspaceID, filename)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
respondJSON(w, map[string][]string{"paths": filePaths})
}
}
func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filePath := chi.URLParam(r, "*")
content, err := fs.GetFileContent(userID, workspaceID, filePath)
if err != nil {
http.Error(w, "Failed to read file", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/plain")
w.Write(content)
}
}
func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filePath := chi.URLParam(r, "*")
content, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
err = fs.SaveFile(userID, workspaceID, filePath, content)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "File saved successfully"})
}
}
func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filePath := chi.URLParam(r, "*")
err = fs.DeleteFile(userID, workspaceID, filePath)
if err != nil {
http.Error(w, "Failed to delete file", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("File deleted successfully"))
}
}
func GetLastOpenedFile(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filePath, err := db.GetLastOpenedFile(workspaceID)
if err != nil {
http.Error(w, "Failed to get last opened file", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"lastOpenedFilePath": filePath})
}
}
func UpdateLastOpenedFile(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var requestBody struct {
FilePath string `json:"filePath"`
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate the file path exists in the workspace
if requestBody.FilePath != "" {
if _, err := fs.ValidatePath(userID, workspaceID, requestBody.FilePath); err != nil {
http.Error(w, "Invalid file path", http.StatusBadRequest)
return
}
}
if err := db.UpdateLastOpenedFile(workspaceID, requestBody.FilePath); err != nil {
http.Error(w, "Failed to update last opened file", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "Last opened file updated successfully"})
}
}

View File

@@ -1,37 +0,0 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
func getUserID(r *http.Request) (int, error) {
userIDStr := chi.URLParam(r, "userId")
return strconv.Atoi(userIDStr)
}
func getUserAndWorkspaceIDs(r *http.Request) (int, int, error) {
userID, err := getUserID(r)
if err != nil {
return 0, 0, errors.New("invalid userId")
}
workspaceIDStr := chi.URLParam(r, "workspaceId")
workspaceID, err := strconv.Atoi(workspaceIDStr)
if err != nil {
return userID, 0, errors.New("invalid workspaceId")
}
return userID, workspaceID, nil
}
func respondJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}

View File

@@ -1,50 +0,0 @@
package api
import (
"novamd/internal/db"
"novamd/internal/filesystem"
"github.com/go-chi/chi/v5"
)
func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) {
r.Route("/", func(r chi.Router) {
// User routes
r.Route("/users/{userId}", func(r chi.Router) {
r.Get("/", GetUser(db))
// Workspace routes
r.Route("/workspaces", func(r chi.Router) {
r.Get("/", ListWorkspaces(db))
r.Post("/", CreateWorkspace(db, fs))
r.Get("/last", GetLastWorkspace(db))
r.Put("/last", UpdateLastWorkspace(db))
r.Route("/{workspaceId}", func(r chi.Router) {
r.Get("/", GetWorkspace(db))
r.Put("/", UpdateWorkspace(db, fs))
r.Delete("/", DeleteWorkspace(db))
// File routes
r.Route("/files", func(r chi.Router) {
r.Get("/", ListFiles(fs))
r.Get("/last", GetLastOpenedFile(db))
r.Put("/last", UpdateLastOpenedFile(db, fs))
r.Get("/lookup", LookupFileByName(fs)) // Moved here
r.Post("/*", SaveFile(fs))
r.Get("/*", GetFileContent(fs))
r.Delete("/*", DeleteFile(fs))
})
// Git routes
r.Route("/git", func(r chi.Router) {
r.Post("/commit", StageCommitAndPush(fs))
r.Post("/pull", PullChanges(fs))
})
})
})
})
})
}

View File

@@ -1,25 +0,0 @@
package api
import (
"net/http"
"novamd/internal/db"
)
func GetUser(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := getUserID(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
user, err := db.GetUserByID(userID)
if err != nil {
http.Error(w, "Failed to get user", http.StatusInternalServerError)
return
}
respondJSON(w, user)
}
}

View File

@@ -1,247 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"novamd/internal/db"
"novamd/internal/filesystem"
"novamd/internal/models"
)
func ListWorkspaces(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := getUserID(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
workspaces, err := db.GetWorkspacesByUserID(userID)
if err != nil {
http.Error(w, "Failed to list workspaces", http.StatusInternalServerError)
return
}
respondJSON(w, workspaces)
}
}
func CreateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := getUserID(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var workspace models.Workspace
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
workspace.UserID = userID
if err := db.CreateWorkspace(&workspace); err != nil {
http.Error(w, "Failed to create workspace", http.StatusInternalServerError)
return
}
if err := fs.InitializeUserWorkspace(workspace.UserID, workspace.ID); err != nil {
http.Error(w, "Failed to initialize workspace directory", http.StatusInternalServerError)
return
}
respondJSON(w, workspace)
}
}
func GetWorkspace(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
workspace, err := db.GetWorkspaceByID(workspaceID)
if err != nil {
http.Error(w, "Workspace not found", http.StatusNotFound)
return
}
if workspace.UserID != userID {
http.Error(w, "Unauthorized access to workspace", http.StatusForbidden)
return
}
respondJSON(w, workspace)
}
}
func UpdateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var workspace models.Workspace
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Set IDs from the request
workspace.ID = workspaceID
workspace.UserID = userID
// Validate the workspace
if err := workspace.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Get current workspace for comparison
currentWorkspace, err := db.GetWorkspaceByID(workspaceID)
if err != nil {
http.Error(w, "Workspace not found", http.StatusNotFound)
return
}
if currentWorkspace.UserID != userID {
http.Error(w, "Unauthorized access to workspace", http.StatusForbidden)
return
}
// Handle Git repository setup/teardown if Git settings changed
if workspace.GitEnabled != currentWorkspace.GitEnabled ||
(workspace.GitEnabled && (workspace.GitURL != currentWorkspace.GitURL ||
workspace.GitUser != currentWorkspace.GitUser ||
workspace.GitToken != currentWorkspace.GitToken)) {
if workspace.GitEnabled {
err = fs.SetupGitRepo(userID, workspaceID, workspace.GitURL, workspace.GitUser, workspace.GitToken)
if err != nil {
http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError)
return
}
} else {
fs.DisableGitRepo(userID, workspaceID)
}
}
if err := db.UpdateWorkspace(&workspace); err != nil {
http.Error(w, "Failed to update workspace", http.StatusInternalServerError)
return
}
respondJSON(w, workspace)
}
}
func DeleteWorkspace(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Check if this is the user's last workspace
workspaces, err := db.GetWorkspacesByUserID(userID)
if err != nil {
http.Error(w, "Failed to get workspaces", http.StatusInternalServerError)
return
}
if len(workspaces) <= 1 {
http.Error(w, "Cannot delete the last workspace", http.StatusBadRequest)
return
}
// Find another workspace to set as last
var nextWorkspaceID int
for _, ws := range workspaces {
if ws.ID != workspaceID {
nextWorkspaceID = ws.ID
break
}
}
// Start transaction
tx, err := db.Begin()
if err != nil {
http.Error(w, "Failed to start transaction", http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Update last workspace ID first
err = db.UpdateLastWorkspaceTx(tx, userID, nextWorkspaceID)
if err != nil {
http.Error(w, "Failed to update last workspace", http.StatusInternalServerError)
return
}
// Delete the workspace
err = db.DeleteWorkspaceTx(tx, workspaceID)
if err != nil {
http.Error(w, "Failed to delete workspace", http.StatusInternalServerError)
return
}
// Commit transaction
if err = tx.Commit(); err != nil {
http.Error(w, "Failed to commit transaction", http.StatusInternalServerError)
return
}
// Return the next workspace ID in the response so frontend knows where to redirect
respondJSON(w, map[string]int{"nextWorkspaceId": nextWorkspaceID})
}
}
func GetLastWorkspace(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := getUserID(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
workspaceID, err := db.GetLastWorkspaceID(userID)
if err != nil {
http.Error(w, "Failed to get last workspace", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]int{"lastWorkspaceId": workspaceID})
}
}
func UpdateLastWorkspace(db *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := getUserID(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var requestBody struct {
WorkspaceID int `json:"workspaceId"`
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := db.UpdateLastWorkspace(userID, requestBody.WorkspaceID); err != nil {
http.Error(w, "Failed to update last workspace", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]string{"message": "Last workspace updated successfully"})
}
}

View File

@@ -1,86 +0,0 @@
package config
import (
"fmt"
"os"
"path/filepath"
"novamd/internal/crypto"
)
type Config struct {
DBPath string
WorkDir string
StaticPath string
Port string
AdminEmail string
AdminPassword string
EncryptionKey string
}
func DefaultConfig() *Config {
return &Config{
DBPath: "./novamd.db",
WorkDir: "./data",
StaticPath: "../frontend/dist",
Port: "8080",
}
}
func (c *Config) Validate() error {
if c.AdminEmail == "" || c.AdminPassword == "" {
return fmt.Errorf("NOVAMD_ADMIN_EMAIL and NOVAMD_ADMIN_PASSWORD must be set")
}
// Validate encryption key
if err := crypto.ValidateKey(c.EncryptionKey); err != nil {
return fmt.Errorf("invalid NOVAMD_ENCRYPTION_KEY: %w", err)
}
return nil
}
// Load creates a new Config instance with values from environment variables
func Load() (*Config, error) {
config := DefaultConfig()
if dbPath := os.Getenv("NOVAMD_DB_PATH"); dbPath != "" {
config.DBPath = dbPath
}
if err := ensureDir(filepath.Dir(config.DBPath)); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
if workDir := os.Getenv("NOVAMD_WORKDIR"); workDir != "" {
config.WorkDir = workDir
}
if err := ensureDir(config.WorkDir); err != nil {
return nil, fmt.Errorf("failed to create work directory: %w", err)
}
if staticPath := os.Getenv("NOVAMD_STATIC_PATH"); staticPath != "" {
config.StaticPath = staticPath
}
if port := os.Getenv("NOVAMD_PORT"); port != "" {
config.Port = port
}
config.AdminEmail = os.Getenv("NOVAMD_ADMIN_EMAIL")
config.AdminPassword = os.Getenv("NOVAMD_ADMIN_PASSWORD")
config.EncryptionKey = os.Getenv("NOVAMD_ENCRYPTION_KEY")
// Validate all settings
if err := config.Validate(); err != nil {
return nil, err
}
return config, nil
}
func ensureDir(dir string) error {
if dir == "" {
return nil
}
return os.MkdirAll(dir, 0755)
}

View File

@@ -1,115 +0,0 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
)
var (
ErrKeyRequired = errors.New("encryption key is required")
ErrInvalidKeySize = errors.New("encryption key must be 32 bytes (256 bits) when decoded")
)
type Crypto struct {
key []byte
}
// ValidateKey checks if the provided key is suitable for AES-256
func ValidateKey(key string) error {
if key == "" {
return ErrKeyRequired
}
// Attempt to decode base64
keyBytes, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return fmt.Errorf("invalid base64 encoding: %w", err)
}
if len(keyBytes) != 32 {
return fmt.Errorf("%w: got %d bytes", ErrInvalidKeySize, len(keyBytes))
}
// Verify the key can be used for AES
_, err = aes.NewCipher(keyBytes)
if err != nil {
return fmt.Errorf("invalid encryption key: %w", err)
}
return nil
}
// New creates a new Crypto instance with the provided base64-encoded key
func New(key string) (*Crypto, error) {
if err := ValidateKey(key); err != nil {
return nil, err
}
keyBytes, _ := base64.StdEncoding.DecodeString(key)
return &Crypto{key: keyBytes}, nil
}
// Encrypt encrypts the plaintext using AES-256-GCM
func (c *Crypto) Encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
block, err := aes.NewCipher(c.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt decrypts the ciphertext using AES-256-GCM
func (c *Crypto) Decrypt(ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
}
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
block, err := aes.NewCipher(c.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}

View File

@@ -1,62 +0,0 @@
package db
import (
"database/sql"
"fmt"
"novamd/internal/crypto"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
*sql.DB
crypto *crypto.Crypto
}
func Init(dbPath string, encryptionKey string) (*DB, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
// Initialize crypto service
cryptoService, err := crypto.New(encryptionKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize encryption: %w", err)
}
database := &DB{
DB: db,
crypto: cryptoService,
}
if err := database.Migrate(); err != nil {
return nil, err
}
return database, nil
}
func (db *DB) Close() error {
return db.DB.Close()
}
// Helper methods for token encryption/decryption
func (db *DB) encryptToken(token string) (string, error) {
if token == "" {
return "", nil
}
return db.crypto.Encrypt(token)
}
func (db *DB) decryptToken(token string) (string, error) {
if token == "" {
return "", nil
}
return db.crypto.Decrypt(token)
}

View File

@@ -1,103 +0,0 @@
package db
import (
"fmt"
"log"
)
type Migration struct {
Version int
SQL string
}
var migrations = []Migration{
{
Version: 1,
SQL: `
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
display_name TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_workspace_id INTEGER
);
-- Create workspaces table with integrated settings
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_opened_file_path TEXT,
-- Settings fields
theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')),
auto_save BOOLEAN NOT NULL DEFAULT 0,
git_enabled BOOLEAN NOT NULL DEFAULT 0,
git_url TEXT,
git_user TEXT,
git_token TEXT,
git_auto_commit BOOLEAN NOT NULL DEFAULT 0,
git_commit_msg_template TEXT DEFAULT '${action} ${filename}',
FOREIGN KEY (user_id) REFERENCES users (id)
);
`,
},
}
func (db *DB) Migrate() error {
// Create migrations table if it doesn't exist
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS migrations (
version INTEGER PRIMARY KEY
)`)
if err != nil {
return err
}
// Get current version
var currentVersion int
err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM migrations").Scan(&currentVersion)
if err != nil {
return err
}
// Apply new migrations
for _, migration := range migrations {
if migration.Version > currentVersion {
log.Printf("Applying migration %d", migration.Version)
tx, err := db.Begin()
if err != nil {
return err
}
_, err = tx.Exec(migration.SQL)
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("migration %d failed: %v, rollback failed: %v", migration.Version, err, rbErr)
}
return fmt.Errorf("migration %d failed: %v", migration.Version, err)
}
_, err = tx.Exec("INSERT INTO migrations (version) VALUES (?)", migration.Version)
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("failed to update migration version: %v, rollback failed: %v", err, rbErr)
}
return fmt.Errorf("failed to update migration version: %v", err)
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("failed to commit migration %d: %v", migration.Version, err)
}
currentVersion = migration.Version
}
}
log.Printf("Database is at version %d", currentVersion)
return nil
}

View File

@@ -1,154 +0,0 @@
package db
import (
"database/sql"
"novamd/internal/models"
)
func (db *DB) CreateUser(user *models.User) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.Exec(`
INSERT INTO users (email, display_name, password_hash, role)
VALUES (?, ?, ?, ?)`,
user.Email, user.DisplayName, user.PasswordHash, user.Role)
if err != nil {
return err
}
userID, err := result.LastInsertId()
if err != nil {
return err
}
user.ID = int(userID)
// Create default workspace with default settings
defaultWorkspace := &models.Workspace{
UserID: user.ID,
Name: "Main",
}
defaultWorkspace.GetDefaultSettings() // Initialize default settings
// Create workspace with settings
err = db.createWorkspaceTx(tx, defaultWorkspace)
if err != nil {
return err
}
// Update user's last workspace ID
_, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", defaultWorkspace.ID, user.ID)
if err != nil {
return err
}
err = tx.Commit()
if err != nil {
return err
}
user.LastWorkspaceID = defaultWorkspace.ID
return nil
}
func (db *DB) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error {
result, err := tx.Exec(`
INSERT INTO workspaces (
user_id, name,
theme, auto_save,
git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
workspace.UserID, workspace.Name,
workspace.Theme, workspace.AutoSave,
workspace.GitEnabled, workspace.GitURL, workspace.GitUser, workspace.GitToken,
workspace.GitAutoCommit, workspace.GitCommitMsgTemplate,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
workspace.ID = int(id)
return nil
}
func (db *DB) GetUserByID(id int) (*models.User, error) {
user := &models.User{}
err := db.QueryRow(`
SELECT
id, email, display_name, role, created_at,
last_workspace_id
FROM users
WHERE id = ?`, id).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &user.CreatedAt,
&user.LastWorkspaceID)
if err != nil {
return nil, err
}
return user, nil
}
func (db *DB) GetUserByEmail(email string) (*models.User, error) {
user := &models.User{}
err := db.QueryRow(`
SELECT
id, email, display_name, password_hash, role, created_at,
last_workspace_id
FROM users
WHERE email = ?`, email).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt,
&user.LastWorkspaceID)
if err != nil {
return nil, err
}
return user, nil
}
func (db *DB) UpdateUser(user *models.User) error {
_, err := db.Exec(`
UPDATE users
SET email = ?, display_name = ?, role = ?, last_workspace_id = ?
WHERE id = ?`,
user.Email, user.DisplayName, user.Role, user.LastWorkspaceID, user.ID)
return err
}
func (db *DB) UpdateLastWorkspace(userID, workspaceID int) error {
_, err := db.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID)
return err
}
func (db *DB) DeleteUser(id int) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Delete all user's workspaces first
_, err = tx.Exec("DELETE FROM workspaces WHERE user_id = ?", id)
if err != nil {
return err
}
// Delete the user
_, err = tx.Exec("DELETE FROM users WHERE id = ?", id)
if err != nil {
return err
}
return tx.Commit()
}
func (db *DB) GetLastWorkspaceID(userID int) (int, error) {
var workspaceID int
err := db.QueryRow("SELECT last_workspace_id FROM users WHERE id = ?", userID).Scan(&workspaceID)
return workspaceID, err
}

View File

@@ -1,209 +0,0 @@
package db
import (
"database/sql"
"fmt"
"novamd/internal/models"
)
func (db *DB) CreateWorkspace(workspace *models.Workspace) error {
// Set default settings if not provided
if workspace.Theme == "" {
workspace.GetDefaultSettings()
}
// Encrypt token if present
encryptedToken, err := db.encryptToken(workspace.GitToken)
if err != nil {
return fmt.Errorf("failed to encrypt token: %w", err)
}
result, err := db.Exec(`
INSERT INTO workspaces (
user_id, name, theme, auto_save,
git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
workspace.UserID, workspace.Name, workspace.Theme, workspace.AutoSave,
workspace.GitEnabled, workspace.GitURL, workspace.GitUser, encryptedToken,
workspace.GitAutoCommit, workspace.GitCommitMsgTemplate,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
workspace.ID = int(id)
return nil
}
func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) {
workspace := &models.Workspace{}
var encryptedToken string
err := db.QueryRow(`
SELECT
id, user_id, name, created_at,
theme, auto_save,
git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template
FROM workspaces
WHERE id = ?`,
id,
).Scan(
&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt,
&workspace.Theme, &workspace.AutoSave,
&workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken,
&workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate,
)
if err != nil {
return nil, err
}
// Decrypt token
workspace.GitToken, err = db.decryptToken(encryptedToken)
if err != nil {
return nil, fmt.Errorf("failed to decrypt token: %w", err)
}
return workspace, nil
}
func (db *DB) UpdateWorkspace(workspace *models.Workspace) error {
// Encrypt token before storing
encryptedToken, err := db.encryptToken(workspace.GitToken)
if err != nil {
return fmt.Errorf("failed to encrypt token: %w", err)
}
_, err = db.Exec(`
UPDATE workspaces
SET
name = ?,
theme = ?,
auto_save = ?,
git_enabled = ?,
git_url = ?,
git_user = ?,
git_token = ?,
git_auto_commit = ?,
git_commit_msg_template = ?
WHERE id = ? AND user_id = ?`,
workspace.Name,
workspace.Theme,
workspace.AutoSave,
workspace.GitEnabled,
workspace.GitURL,
workspace.GitUser,
encryptedToken,
workspace.GitAutoCommit,
workspace.GitCommitMsgTemplate,
workspace.ID,
workspace.UserID,
)
return err
}
func (db *DB) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) {
rows, err := db.Query(`
SELECT
id, user_id, name, created_at,
theme, auto_save,
git_enabled, git_url, git_user, git_token,
git_auto_commit, git_commit_msg_template
FROM workspaces
WHERE user_id = ?`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var workspaces []*models.Workspace
for rows.Next() {
workspace := &models.Workspace{}
var encryptedToken string
err := rows.Scan(
&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt,
&workspace.Theme, &workspace.AutoSave,
&workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken,
&workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate,
)
if err != nil {
return nil, err
}
// Decrypt token
workspace.GitToken, err = db.decryptToken(encryptedToken)
if err != nil {
return nil, fmt.Errorf("failed to decrypt token: %w", err)
}
workspaces = append(workspaces, workspace)
}
return workspaces, nil
}
// UpdateWorkspaceSettings updates only the settings portion of a workspace
// This is useful when you don't want to modify the name or other core workspace properties
func (db *DB) UpdateWorkspaceSettings(workspace *models.Workspace) error {
_, err := db.Exec(`
UPDATE workspaces
SET
theme = ?,
auto_save = ?,
git_enabled = ?,
git_url = ?,
git_user = ?,
git_token = ?,
git_auto_commit = ?,
git_commit_msg_template = ?
WHERE id = ?`,
workspace.Theme,
workspace.AutoSave,
workspace.GitEnabled,
workspace.GitURL,
workspace.GitUser,
workspace.GitToken,
workspace.GitAutoCommit,
workspace.GitCommitMsgTemplate,
workspace.ID,
)
return err
}
func (db *DB) DeleteWorkspace(id int) error {
_, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id)
return err
}
func (db *DB) DeleteWorkspaceTx(tx *sql.Tx, id int) error {
_, err := tx.Exec("DELETE FROM workspaces WHERE id = ?", id)
return err
}
func (db *DB) UpdateLastWorkspaceTx(tx *sql.Tx, userID, workspaceID int) error {
_, err := tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID)
return err
}
func (db *DB) UpdateLastOpenedFile(workspaceID int, filePath string) error {
_, err := db.Exec("UPDATE workspaces SET last_opened_file_path = ? WHERE id = ?", filePath, workspaceID)
return err
}
func (db *DB) GetLastOpenedFile(workspaceID int) (string, error) {
var filePath sql.NullString
err := db.QueryRow("SELECT last_opened_file_path FROM workspaces WHERE id = ?", workspaceID).Scan(&filePath)
if err != nil {
return "", err
}
if !filePath.Valid {
return "", nil
}
return filePath.String, nil
}

View File

@@ -1,249 +0,0 @@
package filesystem
import (
"errors"
"fmt"
"novamd/internal/gitutils"
"os"
"path/filepath"
"sort"
"strings"
)
type FileSystem struct {
RootDir string
GitRepos map[int]map[int]*gitutils.GitRepo // map[userID]map[workspaceID]*gitutils.GitRepo
}
type FileNode struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Children []FileNode `json:"children,omitempty"`
}
func New(rootDir string) *FileSystem {
return &FileSystem{
RootDir: rootDir,
GitRepos: make(map[int]map[int]*gitutils.GitRepo),
}
}
func (fs *FileSystem) GetWorkspacePath(userID, workspaceID int) string {
return filepath.Join(fs.RootDir, fmt.Sprintf("%d", userID), fmt.Sprintf("%d", workspaceID))
}
func (fs *FileSystem) InitializeUserWorkspace(userID, workspaceID int) error {
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
err := os.MkdirAll(workspacePath, 0755)
if err != nil {
return fmt.Errorf("failed to create workspace directory: %w", err)
}
return nil
}
func (fs *FileSystem) DeleteUserWorkspace(userID, workspaceID int) error {
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
err := os.RemoveAll(workspacePath)
if err != nil {
return fmt.Errorf("failed to delete workspace directory: %w", err)
}
return nil
}
func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string, error) {
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
fullPath := filepath.Join(workspacePath, path)
cleanPath := filepath.Clean(fullPath)
if !strings.HasPrefix(cleanPath, workspacePath) {
return "", fmt.Errorf("invalid path: outside of workspace")
}
return cleanPath, nil
}
func (fs *FileSystem) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) {
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
return fs.walkDirectory(workspacePath, "")
}
func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
// Split entries into directories and files
var dirs, files []os.DirEntry
for _, entry := range entries {
if entry.IsDir() {
dirs = append(dirs, entry)
} else {
files = append(files, entry)
}
}
// Sort directories and files separately
sort.Slice(dirs, func(i, j int) bool {
return strings.ToLower(dirs[i].Name()) < strings.ToLower(dirs[j].Name())
})
sort.Slice(files, func(i, j int) bool {
return strings.ToLower(files[i].Name()) < strings.ToLower(files[j].Name())
})
// Create combined slice with directories first, then files
nodes := make([]FileNode, 0, len(entries))
// Add directories first
for _, entry := range dirs {
name := entry.Name()
path := filepath.Join(prefix, name)
fullPath := filepath.Join(dir, name)
children, err := fs.walkDirectory(fullPath, path)
if err != nil {
return nil, err
}
node := FileNode{
ID: path,
Name: name,
Path: path,
Children: children,
}
nodes = append(nodes, node)
}
// Then add files
for _, entry := range files {
name := entry.Name()
path := filepath.Join(prefix, name)
node := FileNode{
ID: path,
Name: name,
Path: path,
}
nodes = append(nodes, node)
}
return nodes, nil
}
func (fs *FileSystem) FindFileByName(userID, workspaceID int, filename string) ([]string, error) {
var foundPaths []string
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
relPath, err := filepath.Rel(workspacePath, path)
if err != nil {
return err
}
if strings.EqualFold(info.Name(), filename) {
foundPaths = append(foundPaths, relPath)
}
}
return nil
})
if err != nil {
return nil, err
}
if len(foundPaths) == 0 {
return nil, errors.New("file not found")
}
return foundPaths, nil
}
func (fs *FileSystem) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) {
fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
if err != nil {
return nil, err
}
return os.ReadFile(fullPath)
}
func (fs *FileSystem) SaveFile(userID, workspaceID int, filePath string, content []byte) error {
fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
if err != nil {
return err
}
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(fullPath, content, 0644)
}
func (fs *FileSystem) DeleteFile(userID, workspaceID int, filePath string) error {
fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
if err != nil {
return err
}
return os.Remove(fullPath)
}
func (fs *FileSystem) CreateWorkspaceDirectory(userID, workspaceID int) error {
dir := fs.GetWorkspacePath(userID, workspaceID)
return os.MkdirAll(dir, 0755)
}
func (fs *FileSystem) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken string) error {
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
if _, ok := fs.GitRepos[userID]; !ok {
fs.GitRepos[userID] = make(map[int]*gitutils.GitRepo)
}
fs.GitRepos[userID][workspaceID] = gitutils.New(gitURL, gitUser, gitToken, workspacePath)
return fs.GitRepos[userID][workspaceID].EnsureRepo()
}
func (fs *FileSystem) DisableGitRepo(userID, workspaceID int) {
if userRepos, ok := fs.GitRepos[userID]; ok {
delete(userRepos, workspaceID)
if len(userRepos) == 0 {
delete(fs.GitRepos, userID)
}
}
}
func (fs *FileSystem) StageCommitAndPush(userID, workspaceID int, message string) error {
repo, ok := fs.getGitRepo(userID, workspaceID)
if !ok {
return errors.New("git settings not configured for this workspace")
}
if err := repo.Commit(message); err != nil {
return err
}
return repo.Push()
}
func (fs *FileSystem) Pull(userID, workspaceID int) error {
repo, ok := fs.getGitRepo(userID, workspaceID)
if !ok {
return errors.New("git settings not configured for this workspace")
}
return repo.Pull()
}
func (fs *FileSystem) getGitRepo(userID, workspaceID int) (*gitutils.GitRepo, bool) {
userRepos, ok := fs.GitRepos[userID]
if !ok {
return nil, false
}
repo, ok := userRepos[workspaceID]
return repo, ok
}

View File

@@ -1,133 +0,0 @@
package gitutils
import (
"fmt"
"os"
"path/filepath"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/transport/http"
)
type GitRepo struct {
URL string
Username string
Token string
WorkDir string
repo *git.Repository
}
func New(url, username, token, workDir string) *GitRepo {
return &GitRepo{
URL: url,
Username: username,
Token: token,
WorkDir: workDir,
}
}
func (g *GitRepo) Clone() error {
auth := &http.BasicAuth{
Username: g.Username,
Password: g.Token,
}
var err error
g.repo, err = git.PlainClone(g.WorkDir, false, &git.CloneOptions{
URL: g.URL,
Auth: auth,
Progress: os.Stdout,
})
if err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
return nil
}
func (g *GitRepo) Pull() error {
if g.repo == nil {
return fmt.Errorf("repository not initialized")
}
w, err := g.repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
auth := &http.BasicAuth{
Username: g.Username,
Password: g.Token,
}
err = w.Pull(&git.PullOptions{
Auth: auth,
Progress: os.Stdout,
})
if err != nil && err != git.NoErrAlreadyUpToDate {
return fmt.Errorf("failed to pull changes: %w", err)
}
return nil
}
func (g *GitRepo) Commit(message string) error {
if g.repo == nil {
return fmt.Errorf("repository not initialized")
}
w, err := g.repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %w", err)
}
_, err = w.Add(".")
if err != nil {
return fmt.Errorf("failed to add changes: %w", err)
}
_, err = w.Commit(message, &git.CommitOptions{})
if err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
}
return nil
}
func (g *GitRepo) Push() error {
if g.repo == nil {
return fmt.Errorf("repository not initialized")
}
auth := &http.BasicAuth{
Username: g.Username,
Password: g.Token,
}
err := g.repo.Push(&git.PushOptions{
Auth: auth,
Progress: os.Stdout,
})
if err != nil && err != git.NoErrAlreadyUpToDate {
return fmt.Errorf("failed to push changes: %w", err)
}
return nil
}
func (g *GitRepo) EnsureRepo() error {
if _, err := os.Stat(filepath.Join(g.WorkDir, ".git")); os.IsNotExist(err) {
return g.Clone()
}
var err error
g.repo, err = git.PlainOpen(g.WorkDir)
if err != nil {
return fmt.Errorf("failed to open existing repository: %w", err)
}
return g.Pull()
}

View File

@@ -1,115 +0,0 @@
package user
import (
"database/sql"
"fmt"
"log"
"golang.org/x/crypto/bcrypt"
"novamd/internal/db"
"novamd/internal/filesystem"
"novamd/internal/models"
)
type UserService struct {
DB *db.DB
FS *filesystem.FileSystem
}
func NewUserService(database *db.DB, fs *filesystem.FileSystem) *UserService {
return &UserService{
DB: database,
FS: fs,
}
}
func (s *UserService) SetupAdminUser(adminEmail, adminPassword string) (*models.User, error) {
// Check if admin user exists
adminUser, err := s.DB.GetUserByEmail(adminEmail)
if adminUser != nil {
return adminUser, nil // Admin user already exists
} else if err != sql.ErrNoRows {
return nil, err
}
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Create admin user
adminUser = &models.User{
Email: adminEmail,
DisplayName: "Admin",
PasswordHash: string(hashedPassword),
Role: models.RoleAdmin,
}
err = s.DB.CreateUser(adminUser)
if err != nil {
return nil, fmt.Errorf("failed to create admin user: %w", err)
}
// Initialize workspace directory
err = s.FS.InitializeUserWorkspace(adminUser.ID, adminUser.LastWorkspaceID)
if err != nil {
return nil, fmt.Errorf("failed to initialize admin workspace: %w", err)
}
log.Printf("Created admin user with ID: %d and default workspace with ID: %d", adminUser.ID, adminUser.LastWorkspaceID)
return adminUser, nil
}
func (s *UserService) CreateUser(user *models.User) error {
err := s.DB.CreateUser(user)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
err = s.FS.InitializeUserWorkspace(user.ID, user.LastWorkspaceID)
if err != nil {
return fmt.Errorf("failed to initialize user workspace: %w", err)
}
return nil
}
func (s *UserService) GetUserByID(id int) (*models.User, error) {
return s.DB.GetUserByID(id)
}
func (s *UserService) GetUserByEmail(email string) (*models.User, error) {
return s.DB.GetUserByEmail(email)
}
func (s *UserService) UpdateUser(user *models.User) error {
return s.DB.UpdateUser(user)
}
func (s *UserService) DeleteUser(id int) error {
// First, get the user to check if they exist
user, err := s.DB.GetUserByID(id)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
// Get user's workspaces
workspaces, err := s.DB.GetWorkspacesByUserID(id)
if err != nil {
return fmt.Errorf("failed to get user's workspaces: %w", err)
}
// Delete workspace directories
for _, workspace := range workspaces {
err = s.FS.DeleteUserWorkspace(user.ID, workspace.ID)
if err != nil {
return fmt.Errorf("failed to delete workspace files: %w", err)
}
}
// Delete user from database (this will cascade delete workspaces)
return s.DB.DeleteUser(id)
}

View File

@@ -1,21 +0,0 @@
import React from 'react';
import { Group, Text, Avatar } from '@mantine/core';
import WorkspaceSwitcher from './WorkspaceSwitcher';
import Settings from './Settings';
const Header = () => {
return (
<Group justify="space-between" h={60} px="md">
<Text fw={700} size="lg">
NovaMD
</Text>
<Group>
<WorkspaceSwitcher />
<Avatar src="https://via.placeholder.com/40" radius="xl" />
</Group>
<Settings />
</Group>
);
};
export default Header;

View File

@@ -1,159 +0,0 @@
import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import 'katex/dist/katex.min.css';
import { lookupFileByName } from '../services/api';
const MarkdownPreview = ({ content, handleLinkClick }) => {
const [processedContent, setProcessedContent] = useState(content);
const baseUrl = window.API_BASE_URL;
useEffect(() => {
const processContent = async (rawContent) => {
const regex = /(!?)\[\[(.*?)\]\]/g;
let result = rawContent;
const matches = [...rawContent.matchAll(regex)];
for (const match of matches) {
const [fullMatch, isImage, innerContent] = match;
let fileName, displayText, heading;
// Parse the inner content
const pipeIndex = innerContent.indexOf('|');
const hashIndex = innerContent.indexOf('#');
if (pipeIndex !== -1) {
displayText = innerContent.slice(pipeIndex + 1).trim();
fileName = innerContent.slice(0, pipeIndex).trim();
} else {
displayText = innerContent;
fileName = innerContent;
}
if (hashIndex !== -1 && (pipeIndex === -1 || hashIndex < pipeIndex)) {
heading = fileName.slice(hashIndex + 1).trim();
fileName = fileName.slice(0, hashIndex).trim();
}
try {
const paths = await lookupFileByName(fileName);
if (paths && paths.length > 0) {
const filePath = paths[0];
if (isImage) {
result = result.replace(
fullMatch,
`![${displayText}](${baseUrl}/files/${filePath})`
);
} else {
// Include heading in the URL if present
const url = heading
? `${baseUrl}/internal/${encodeURIComponent(
filePath
)}#${encodeURIComponent(heading)}`
: `${baseUrl}/internal/${encodeURIComponent(filePath)}`;
result = result.replace(fullMatch, `[${displayText}](${url})`);
}
} else {
result = result.replace(
fullMatch,
`[${displayText}](${baseUrl}/notfound/${encodeURIComponent(
fileName
)})`
);
}
} catch (error) {
console.error('Error looking up file:', error);
result = result.replace(
fullMatch,
`[${displayText}](${baseUrl}/notfound/${encodeURIComponent(
fileName
)})`
);
}
}
return result;
};
processContent(content).then(setProcessedContent);
}, [content, baseUrl]);
const handleImageError = (event) => {
console.error('Failed to load image:', event.target.src);
event.target.alt = 'Failed to load image';
};
return (
<div className="markdown-preview">
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
img: ({ src, alt, ...props }) => (
<img src={src} alt={alt} onError={handleImageError} {...props} />
),
a: ({ href, children }) => {
if (href.startsWith(`${baseUrl}/internal/`)) {
const [filePath, heading] = decodeURIComponent(
href.replace(`${baseUrl}/internal/`, '')
).split('#');
return (
<a
href="#"
onClick={(e) => {
e.preventDefault();
handleLinkClick(filePath, heading);
}}
>
{children}
</a>
);
} else if (href.startsWith(`${baseUrl}/notfound/`)) {
const fileName = decodeURIComponent(
href.replace(`${baseUrl}/notfound/`, '')
);
return (
<a
href="#"
style={{ color: 'red', textDecoration: 'underline' }}
onClick={(e) => {
e.preventDefault();
handleLinkClick(fileName);
}}
>
{children}
</a>
);
}
// Regular markdown link
return <a href={href}>{children}</a>;
},
}}
>
{processedContent}
</ReactMarkdown>
</div>
);
};
export default MarkdownPreview;

View File

@@ -1,21 +0,0 @@
import React from 'react';
import { Text, Switch, Tooltip, Group, Box, Title } from '@mantine/core';
const EditorSettings = ({ autoSave, onAutoSaveChange }) => {
return (
<Box mb="md">
<Tooltip label="Auto Save feature is coming soon!" position="left">
<Group justify="space-between" align="center">
<Text size="sm">Auto Save</Text>
<Switch
checked={autoSave}
onChange={(event) => onAutoSaveChange(event.currentTarget.checked)}
disabled
/>
</Group>
</Tooltip>
</Box>
);
};
export default EditorSettings;

View File

@@ -1,178 +0,0 @@
const API_BASE_URL = window.API_BASE_URL;
const apiCall = async (url, options = {}) => {
try {
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(
errorData?.message || `HTTP error! status: ${response.status}`
);
}
return response;
} catch (error) {
console.error(`API call failed: ${error.message}`);
throw error;
}
};
export const fetchLastWorkspaceId = async () => {
const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`);
return response.json();
};
export const fetchFileList = async (workspaceId) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files`
);
return response.json();
};
export const fetchFileContent = async (workspaceId, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`
);
return response.text();
};
export const saveFileContent = async (workspaceId, filePath, content) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`,
{
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
body: content,
}
);
return response.text();
};
export const deleteFile = async (workspaceId, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`,
{
method: 'DELETE',
}
);
return response.text();
};
export const getWorkspace = async (workspaceId) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}`
);
return response.json();
};
// Combined function to update workspace data including settings
export const updateWorkspace = async (workspaceId, workspaceData) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(workspaceData),
}
);
return response.json();
};
export const pullChanges = async (workspaceId) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/git/pull`,
{
method: 'POST',
}
);
return response.json();
};
export const commitAndPush = async (workspaceId, message) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/git/commit`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message }),
}
);
return response.json();
};
export const getFileUrl = (workspaceId, filePath) => {
return `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`;
};
export const lookupFileByName = async (workspaceId, filename) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/lookup?filename=${encodeURIComponent(
filename
)}`
);
const data = await response.json();
return data.paths;
};
export const updateLastOpenedFile = async (workspaceId, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/last`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filePath }),
}
);
return response.json();
};
export const getLastOpenedFile = async (workspaceId) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/last`
);
return response.json();
};
export const listWorkspaces = async () => {
const response = await apiCall(`${API_BASE_URL}/users/1/workspaces`);
return response.json();
};
export const createWorkspace = async (name) => {
const response = await apiCall(`${API_BASE_URL}/users/1/workspaces`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
});
return response.json();
};
export const deleteWorkspace = async (workspaceId) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}`,
{
method: 'DELETE',
}
);
return response.json();
};
export const updateLastWorkspace = async (workspaceId) => {
const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ workspaceId }),
});
return response.json();
};

33
server/cmd/server/main.go Normal file
View File

@@ -0,0 +1,33 @@
// Package main provides the entry point for the application. It loads the configuration, initializes the server, and starts the server.
package main
import (
"log"
"novamd/internal/app"
"novamd/internal/config"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load configuration:", err)
}
// Initialize and start server
server, err := app.NewServer(cfg)
if err != nil {
log.Fatal("Failed to initialize server:", err)
}
defer func() {
if err := server.Close(); err != nil {
log.Println("Error closing server:", err)
}
}()
// Start server
if err := server.Start(); err != nil {
log.Fatal("Server error:", err)
}
}

50
server/gendocs.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/bin/bash
set -euo pipefail
# Function to generate anchor from package path
generate_anchor() {
echo "$1" | tr '/' '-'
}
# Create documentation file
echo "# NovaMD Package Documentation
Generated documentation for all packages in the NovaMD project.
## Table of Contents
" > documentation.md
# Find all directories containing .go files (excluding test files)
# Sort them for consistent output
PACKAGES=$(find . -type f -name "*.go" ! -name "*_test.go" -exec dirname {} \; | sort -u | grep -v "/\.")
# Generate table of contents
for PKG in $PACKAGES; do
# Strip leading ./
PKG_PATH=${PKG#./}
# Skip if empty
[ -z "$PKG_PATH" ] && continue
ANCHOR=$(generate_anchor "$PKG_PATH")
echo "- [$PKG_PATH](#$ANCHOR)" >> documentation.md
done
echo "" >> documentation.md
# Generate documentation for each package
for PKG in $PACKAGES; do
# Strip leading ./
PKG_PATH=${PKG#./}
# Skip if empty
[ -z "$PKG_PATH" ] && continue
echo "## $PKG_PATH" >> documentation.md
echo "" >> documentation.md
echo '```go' >> documentation.md
go doc -all "./$PKG_PATH" >> documentation.md
echo '```' >> documentation.md
echo "" >> documentation.md
done
echo "Documentation generated in documentation.md"

View File

@@ -4,9 +4,15 @@ go 1.23.1
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1
github.com/go-chi/httprate v0.14.1
github.com/go-git/go-git/v5 v5.12.0
github.com/go-playground/validator/v10 v10.22.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.23
github.com/stretchr/testify v1.9.0
github.com/unrolled/secure v1.17.0
golang.org/x/crypto v0.21.0
)
@@ -14,8 +20,10 @@ require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
@@ -27,6 +35,7 @@ require (
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
@@ -36,4 +45,5 @@ require (
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.13.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -10,6 +10,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
@@ -28,6 +30,10 @@ github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs=
github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
@@ -44,10 +50,14 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
@@ -83,6 +93,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -109,8 +121,8 @@ golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@@ -0,0 +1,97 @@
// Package api contains the API routes for the application. It sets up the routes for the public and protected endpoints, as well as the admin-only routes.
package api
import (
"novamd/internal/auth"
"novamd/internal/context"
"novamd/internal/db"
"novamd/internal/handlers"
"novamd/internal/storage"
"github.com/go-chi/chi/v5"
)
// SetupRoutes configures the API routes
func SetupRoutes(r chi.Router, db db.Database, s storage.Manager, authMiddleware *auth.Middleware, sessionService *auth.SessionService) {
handler := &handlers.Handler{
DB: db,
Storage: s,
}
// Public routes (no authentication required)
r.Group(func(r chi.Router) {
r.Post("/auth/login", handler.Login(sessionService))
r.Post("/auth/refresh", handler.RefreshToken(sessionService))
})
// Protected routes (authentication required)
r.Group(func(r chi.Router) {
// Apply authentication middleware to all routes in this group
r.Use(authMiddleware.Authenticate)
r.Use(context.WithUserContextMiddleware)
// Auth routes
r.Post("/auth/logout", handler.Logout(sessionService))
r.Get("/auth/me", handler.GetCurrentUser())
// User profile routes
r.Put("/profile", handler.UpdateProfile())
r.Delete("/profile", handler.DeleteAccount())
// Admin-only routes
r.Route("/admin", func(r chi.Router) {
r.Use(authMiddleware.RequireRole("admin"))
// User management
r.Route("/users", func(r chi.Router) {
r.Get("/", handler.AdminListUsers())
r.Post("/", handler.AdminCreateUser())
r.Get("/{userId}", handler.AdminGetUser())
r.Put("/{userId}", handler.AdminUpdateUser())
r.Delete("/{userId}", handler.AdminDeleteUser())
})
// Workspace management
r.Route("/workspaces", func(r chi.Router) {
r.Get("/", handler.AdminListWorkspaces())
})
// System stats
r.Get("/stats", handler.AdminGetSystemStats())
})
// Workspace routes
r.Route("/workspaces", func(r chi.Router) {
r.Get("/", handler.ListWorkspaces())
r.Post("/", handler.CreateWorkspace())
r.Get("/last", handler.GetLastWorkspaceName())
r.Put("/last", handler.UpdateLastWorkspaceName())
// Single workspace routes
r.Route("/{workspaceName}", func(r chi.Router) {
r.Use(context.WithWorkspaceContextMiddleware(db))
r.Use(authMiddleware.RequireWorkspaceAccess)
r.Get("/", handler.GetWorkspace())
r.Put("/", handler.UpdateWorkspace())
r.Delete("/", handler.DeleteWorkspace())
// File routes
r.Route("/files", func(r chi.Router) {
r.Get("/", handler.ListFiles())
r.Get("/last", handler.GetLastOpenedFile())
r.Put("/last", handler.UpdateLastOpenedFile())
r.Get("/lookup", handler.LookupFileByName())
r.Post("/*", handler.SaveFile())
r.Get("/*", handler.GetFileContent())
r.Delete("/*", handler.DeleteFile())
})
// Git routes
r.Route("/git", func(r chi.Router) {
r.Post("/commit", handler.StageCommitAndPush())
r.Post("/pull", handler.PullChanges())
})
})
})
})
}

223
server/internal/app/app.go Normal file
View File

@@ -0,0 +1,223 @@
// Package app provides application-level functionality for initializing and running the server
package app
import (
"database/sql"
"fmt"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"github.com/unrolled/secure"
"golang.org/x/crypto/bcrypt"
"novamd/internal/api"
"novamd/internal/auth"
"novamd/internal/config"
"novamd/internal/db"
"novamd/internal/handlers"
"novamd/internal/models"
"novamd/internal/secrets"
"novamd/internal/storage"
)
// Server represents the HTTP server and its dependencies
type Server struct {
router *chi.Mux
config *config.Config
db db.Database
storage storage.Manager
}
// NewServer initializes a new server instance with all dependencies
func NewServer(cfg *config.Config) (*Server, error) {
// Initialize secrets service
secretsService, err := secrets.NewService(cfg.EncryptionKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize secrets service: %w", err)
}
// Initialize database
database, err := initDatabase(cfg, secretsService)
if err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
// Initialize filesystem
storageManager := storage.NewService(cfg.WorkDir)
// Setup admin user
err = setupAdminUser(database, storageManager, cfg)
if err != nil {
return nil, fmt.Errorf("failed to setup admin user: %w", err)
}
// Initialize router
router := initRouter(cfg)
return &Server{
router: router,
config: cfg,
db: database,
storage: storageManager,
}, nil
}
// Start configures and starts the HTTP server
func (s *Server) Start() error {
// Set up authentication
jwtManager, sessionService, err := s.setupAuth()
if err != nil {
return fmt.Errorf("failed to setup authentication: %w", err)
}
// Set up routes
s.setupRoutes(jwtManager, sessionService)
// Start server
addr := ":" + s.config.Port
log.Printf("Server starting on port %s", s.config.Port)
return http.ListenAndServe(addr, s.router)
}
// Close handles graceful shutdown of server dependencies
func (s *Server) Close() error {
return s.db.Close()
}
// initDatabase initializes and migrates the database
func initDatabase(cfg *config.Config, secretsService secrets.Service) (db.Database, error) {
database, err := db.Init(cfg.DBPath, secretsService)
if err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
if err := database.Migrate(); err != nil {
return nil, fmt.Errorf("failed to apply database migrations: %w", err)
}
return database, nil
}
// initRouter creates and configures the chi router with middleware
func initRouter(cfg *config.Config) *chi.Mux {
r := chi.NewRouter()
// Basic middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Timeout(30 * time.Second))
// Security headers
r.Use(secure.New(secure.Options{
SSLRedirect: false, // Let proxy handle HTTPS
SSLProxyHeaders: map[string]string{"X-Forwarded-Proto": "https"},
IsDevelopment: cfg.IsDevelopment,
}).Handler)
// CORS if origins are configured
if len(cfg.CORSOrigins) > 0 {
r.Use(cors.Handler(cors.Options{
AllowedOrigins: cfg.CORSOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Requested-With"},
AllowCredentials: true,
MaxAge: 300,
}))
}
return r
}
// setupAuth initializes JWT and session services
func (s *Server) setupAuth() (auth.JWTManager, *auth.SessionService, error) {
// Get or generate JWT signing key
signingKey := s.config.JWTSigningKey
if signingKey == "" {
var err error
signingKey, err = s.db.EnsureJWTSecret()
if err != nil {
return nil, nil, fmt.Errorf("failed to ensure JWT secret: %w", err)
}
}
// Initialize JWT service
jwtManager, err := auth.NewJWTService(auth.JWTConfig{
SigningKey: signingKey,
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 7 * 24 * time.Hour,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to initialize JWT service: %w", err)
}
// Initialize session service
sessionService := auth.NewSessionService(s.db, jwtManager)
return jwtManager, sessionService, nil
}
// setupRoutes configures all application routes
func (s *Server) setupRoutes(jwtManager auth.JWTManager, sessionService *auth.SessionService) {
// Initialize auth middleware
authMiddleware := auth.NewMiddleware(jwtManager)
// Set up API routes
s.router.Route("/api/v1", func(r chi.Router) {
r.Use(httprate.LimitByIP(s.config.RateLimitRequests, s.config.RateLimitWindow))
api.SetupRoutes(r, s.db, s.storage, authMiddleware, sessionService)
})
// Handle all other routes with static file server
s.router.Get("/*", handlers.NewStaticHandler(s.config.StaticPath).ServeHTTP)
}
func setupAdminUser(db db.Database, w storage.WorkspaceManager, cfg *config.Config) error {
adminEmail := cfg.AdminEmail
adminPassword := cfg.AdminPassword
// Check if admin user exists
adminUser, err := db.GetUserByEmail(adminEmail)
if adminUser != nil {
return nil // Admin user already exists
} else if err != sql.ErrNoRows {
return err
}
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
// Create admin user
adminUser = &models.User{
Email: adminEmail,
DisplayName: "Admin",
PasswordHash: string(hashedPassword),
Role: models.RoleAdmin,
}
createdUser, err := db.CreateUser(adminUser)
if err != nil {
return fmt.Errorf("failed to create admin user: %w", err)
}
// Initialize workspace directory
err = w.InitializeUserWorkspace(createdUser.ID, createdUser.LastWorkspaceID)
if err != nil {
return fmt.Errorf("failed to initialize admin workspace: %w", err)
}
log.Printf("Created admin user with ID: %d and default workspace with ID: %d", createdUser.ID, createdUser.LastWorkspaceID)
return nil
}

134
server/internal/auth/jwt.go Normal file
View File

@@ -0,0 +1,134 @@
// Package auth provides JWT token generation and validation
package auth
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// TokenType represents the type of JWT token (access or refresh)
type TokenType string
const (
AccessToken TokenType = "access" // AccessToken - Short-lived token for API access
RefreshToken TokenType = "refresh" // RefreshToken - Long-lived token for obtaining new access tokens
)
// Claims represents the custom claims we store in JWT tokens
type Claims struct {
jwt.RegisteredClaims // Embedded standard JWT claims
UserID int `json:"uid"` // User identifier
Role string `json:"role"` // User role (admin, editor, viewer)
Type TokenType `json:"type"` // Token type (access or refresh)
}
// JWTConfig holds the configuration for the JWT service
type JWTConfig struct {
SigningKey string // Secret key used to sign tokens
AccessTokenExpiry time.Duration // How long access tokens are valid
RefreshTokenExpiry time.Duration // How long refresh tokens are valid
}
// JWTManager defines the interface for managing JWT tokens
type JWTManager interface {
GenerateAccessToken(userID int, role string) (string, error)
GenerateRefreshToken(userID int, role string) (string, error)
ValidateToken(tokenString string) (*Claims, error)
RefreshAccessToken(refreshToken string) (string, error)
}
// jwtService handles JWT token generation and validation
type jwtService struct {
config JWTConfig
}
// NewJWTService creates a new JWT service with the provided configuration
// Returns an error if the signing key is missing
func NewJWTService(config JWTConfig) (JWTManager, error) {
if config.SigningKey == "" {
return nil, fmt.Errorf("signing key is required")
}
// Set default expiry times if not provided
if config.AccessTokenExpiry == 0 {
config.AccessTokenExpiry = 15 * time.Minute // Default to 15 minutes
}
if config.RefreshTokenExpiry == 0 {
config.RefreshTokenExpiry = 7 * 24 * time.Hour // Default to 7 days
}
return &jwtService{config: config}, nil
}
// GenerateAccessToken creates a new access token for a user with the given userID and role
func (s *jwtService) GenerateAccessToken(userID int, role string) (string, error) {
return s.generateToken(userID, role, AccessToken, s.config.AccessTokenExpiry)
}
// GenerateRefreshToken creates a new refresh token for a user with the given userID and role
func (s *jwtService) GenerateRefreshToken(userID int, role string) (string, error) {
return s.generateToken(userID, role, RefreshToken, s.config.RefreshTokenExpiry)
}
// generateToken is an internal helper function that creates a new JWT token
func (s *jwtService) generateToken(userID int, role string, tokenType TokenType, expiry time.Duration) (string, error) {
now := time.Now()
// Add a random nonce to ensure uniqueness
nonce := make([]byte, 8)
if _, err := rand.Read(nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(expiry)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ID: hex.EncodeToString(nonce),
},
UserID: userID,
Role: role,
Type: tokenType,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.config.SigningKey))
}
// ValidateToken validates and parses a JWT token
func (s *jwtService) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
// Validate the signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(s.config.SigningKey), nil
})
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token claims")
}
// RefreshAccessToken creates a new access token using a refreshToken
func (s *jwtService) RefreshAccessToken(refreshToken string) (string, error) {
claims, err := s.ValidateToken(refreshToken)
if err != nil {
return "", fmt.Errorf("invalid refresh token: %w", err)
}
if claims.Type != RefreshToken {
return "", fmt.Errorf("invalid token type: expected refresh token")
}
return s.GenerateAccessToken(claims.UserID, claims.Role)
}

View File

@@ -0,0 +1,221 @@
package auth_test
import (
"testing"
"time"
"novamd/internal/auth"
"github.com/golang-jwt/jwt/v5"
)
// jwt_test.go tests
func TestNewJWTService(t *testing.T) {
testCases := []struct {
name string
config auth.JWTConfig
wantErr bool
}{
{
name: "valid configuration",
config: auth.JWTConfig{
SigningKey: "test-key",
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 24 * time.Hour,
},
wantErr: false,
},
{
name: "missing signing key",
config: auth.JWTConfig{
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 24 * time.Hour,
},
wantErr: true,
},
{
name: "zero expiry times",
config: auth.JWTConfig{
SigningKey: "test-key",
},
wantErr: false, // Should use default values
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
service, err := auth.NewJWTService(tc.config)
if tc.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if service == nil {
t.Error("expected service, got nil")
}
})
}
}
func TestGenerateAndValidateToken(t *testing.T) {
config := auth.JWTConfig{
SigningKey: "test-key",
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 24 * time.Hour,
}
service, _ := auth.NewJWTService(config)
testCases := []struct {
name string
userID int
role string
tokenType auth.TokenType
wantErr bool
}{
{
name: "valid access token",
userID: 1,
role: "admin",
tokenType: auth.AccessToken,
wantErr: false,
},
{
name: "valid refresh token",
userID: 1,
role: "editor",
tokenType: auth.RefreshToken,
wantErr: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var token string
var err error
// Generate token based on type
if tc.tokenType == auth.AccessToken {
token, err = service.GenerateAccessToken(tc.userID, tc.role)
} else {
token, err = service.GenerateRefreshToken(tc.userID, tc.role)
}
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
// Validate token
claims, err := service.ValidateToken(token)
if tc.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
// Verify claims
if claims.UserID != tc.userID {
t.Errorf("userID = %v, want %v", claims.UserID, tc.userID)
}
if claims.Role != tc.role {
t.Errorf("role = %v, want %v", claims.Role, tc.role)
}
if claims.Type != tc.tokenType {
t.Errorf("type = %v, want %v", claims.Type, tc.tokenType)
}
})
}
}
func TestRefreshAccessToken(t *testing.T) {
config := auth.JWTConfig{
SigningKey: "test-key",
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 24 * time.Hour,
}
service, _ := auth.NewJWTService(config)
testCases := []struct {
name string
userID int
role string
wantErr bool
setupFunc func() string // Added setup function to handle custom token creation
}{
{
name: "valid refresh token",
userID: 1,
role: "admin",
wantErr: false,
setupFunc: func() string {
token, _ := service.GenerateRefreshToken(1, "admin")
return token
},
},
{
name: "expired refresh token",
userID: 1,
role: "admin",
wantErr: true,
setupFunc: func() string {
// Create a token that's already expired
claims := &auth.Claims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // Expired 1 hour ago
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
},
UserID: 1,
Role: "admin",
Type: auth.RefreshToken,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, _ := token.SignedString([]byte(config.SigningKey))
return tokenString
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
refreshToken := tc.setupFunc()
newAccessToken, err := service.RefreshAccessToken(refreshToken)
if tc.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
claims, err := service.ValidateToken(newAccessToken)
if err != nil {
t.Fatalf("failed to validate new access token: %v", err)
}
if claims.UserID != tc.userID {
t.Errorf("userID = %v, want %v", claims.UserID, tc.userID)
}
if claims.Role != tc.role {
t.Errorf("role = %v, want %v", claims.Role, tc.role)
}
if claims.Type != auth.AccessToken {
t.Errorf("token type = %v, want %v", claims.Type, auth.AccessToken)
}
})
}
}

View File

@@ -0,0 +1,104 @@
package auth
import (
"net/http"
"strings"
"novamd/internal/context"
)
// Middleware handles JWT authentication for protected routes
type Middleware struct {
jwtManager JWTManager
}
// NewMiddleware creates a new authentication middleware
func NewMiddleware(jwtManager JWTManager) *Middleware {
return &Middleware{
jwtManager: jwtManager,
}
}
// Authenticate middleware validates JWT tokens and sets user information in context
func (m *Middleware) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract token from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization header required", http.StatusUnauthorized)
return
}
// Check Bearer token format
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Invalid authorization format", http.StatusUnauthorized)
return
}
// Validate token
claims, err := m.jwtManager.ValidateToken(parts[1])
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
// Check token type
if claims.Type != AccessToken {
http.Error(w, "Invalid token type", http.StatusUnauthorized)
return
}
// Create handler context with user information
hctx := &context.HandlerContext{
UserID: claims.UserID,
UserRole: claims.Role,
}
// Add context to request and continue
next.ServeHTTP(w, context.WithHandlerContext(r, hctx))
})
}
// RequireRole returns a middleware that ensures the user has the required role
func (m *Middleware) RequireRole(role string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r)
if !ok {
return
}
if ctx.UserRole != role && ctx.UserRole != "admin" {
http.Error(w, "Insufficient permissions", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireWorkspaceAccess returns a middleware that ensures the user has access to the workspace
func (m *Middleware) RequireWorkspaceAccess(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r)
if !ok {
return
}
// If no workspace in context, allow the request (might be a non-workspace endpoint)
if ctx.Workspace == nil {
next.ServeHTTP(w, r)
return
}
// Check if user has access (either owner or admin)
if ctx.Workspace.UserID != ctx.UserID && ctx.UserRole != "admin" {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,294 @@
package auth_test
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"novamd/internal/auth"
"novamd/internal/context"
"novamd/internal/models"
)
// Complete mockResponseWriter implementation
type mockResponseWriter struct {
headers http.Header
statusCode int
written []byte
}
func newMockResponseWriter() *mockResponseWriter {
return &mockResponseWriter{
headers: make(http.Header),
}
}
func (m *mockResponseWriter) Header() http.Header {
return m.headers
}
func (m *mockResponseWriter) Write(b []byte) (int, error) {
m.written = b
return len(b), nil
}
func (m *mockResponseWriter) WriteHeader(statusCode int) {
m.statusCode = statusCode
}
func TestAuthenticateMiddleware(t *testing.T) {
config := auth.JWTConfig{
SigningKey: "test-key",
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 24 * time.Hour,
}
jwtService, _ := auth.NewJWTService(config)
middleware := auth.NewMiddleware(jwtService)
testCases := []struct {
name string
setupAuth func() string
wantStatusCode int
}{
{
name: "valid token",
setupAuth: func() string {
token, _ := jwtService.GenerateAccessToken(1, "admin")
return token
},
wantStatusCode: http.StatusOK,
},
{
name: "missing auth header",
setupAuth: func() string {
return ""
},
wantStatusCode: http.StatusUnauthorized,
},
{
name: "invalid auth format",
setupAuth: func() string {
return "InvalidFormat token"
},
wantStatusCode: http.StatusUnauthorized,
},
{
name: "invalid token",
setupAuth: func() string {
return "Bearer invalid.token.here"
},
wantStatusCode: http.StatusUnauthorized,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create test request
req := httptest.NewRequest("GET", "/test", nil)
if token := tc.setupAuth(); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
// Create response recorder
w := newMockResponseWriter()
// Create test handler
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
})
// Execute middleware
middleware.Authenticate(next).ServeHTTP(w, req)
// Check status code
if w.statusCode != tc.wantStatusCode {
t.Errorf("status code = %v, want %v", w.statusCode, tc.wantStatusCode)
}
// Check if next handler was called when expected
if tc.wantStatusCode == http.StatusOK && !nextCalled {
t.Error("next handler was not called")
}
if tc.wantStatusCode != http.StatusOK && nextCalled {
t.Error("next handler was called when it shouldn't have been")
}
})
}
}
func TestRequireRole(t *testing.T) {
config := auth.JWTConfig{
SigningKey: "test-key",
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 24 * time.Hour,
}
jwtService, _ := auth.NewJWTService(config)
middleware := auth.NewMiddleware(jwtService)
testCases := []struct {
name string
userRole string
requiredRole string
wantStatusCode int
}{
{
name: "matching role",
userRole: "admin",
requiredRole: "admin",
wantStatusCode: http.StatusOK,
},
{
name: "admin accessing other role",
userRole: "admin",
requiredRole: "editor",
wantStatusCode: http.StatusOK,
},
{
name: "insufficient role",
userRole: "editor",
requiredRole: "admin",
wantStatusCode: http.StatusForbidden,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create handler context with user info
hctx := &context.HandlerContext{
UserID: 1,
UserRole: tc.userRole,
}
// Create request with handler context
req := httptest.NewRequest("GET", "/test", nil)
req = context.WithHandlerContext(req, hctx)
w := newMockResponseWriter()
// Create test handler
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
})
// Execute middleware
middleware.RequireRole(tc.requiredRole)(next).ServeHTTP(w, req)
// Check status code
if w.statusCode != tc.wantStatusCode {
t.Errorf("status code = %v, want %v", w.statusCode, tc.wantStatusCode)
}
// Check if next handler was called when expected
if tc.wantStatusCode == http.StatusOK && !nextCalled {
t.Error("next handler was not called")
}
if tc.wantStatusCode != http.StatusOK && nextCalled {
t.Error("next handler was called when it shouldn't have been")
}
})
}
}
func TestRequireWorkspaceAccess(t *testing.T) {
config := auth.JWTConfig{
SigningKey: "test-key",
}
jwtService, _ := auth.NewJWTService(config)
middleware := auth.NewMiddleware(jwtService)
testCases := []struct {
name string
setupContext func() *context.HandlerContext
wantStatusCode int
}{
{
name: "workspace owner access",
setupContext: func() *context.HandlerContext {
return &context.HandlerContext{
UserID: 1,
UserRole: "editor",
Workspace: &models.Workspace{
ID: 1,
UserID: 1, // Same as context UserID
},
}
},
wantStatusCode: http.StatusOK,
},
{
name: "admin access to other's workspace",
setupContext: func() *context.HandlerContext {
return &context.HandlerContext{
UserID: 2,
UserRole: "admin",
Workspace: &models.Workspace{
ID: 1,
UserID: 1, // Different from context UserID
},
}
},
wantStatusCode: http.StatusOK,
},
{
name: "unauthorized access attempt",
setupContext: func() *context.HandlerContext {
return &context.HandlerContext{
UserID: 2,
UserRole: "editor",
Workspace: &models.Workspace{
ID: 1,
UserID: 1, // Different from context UserID
},
}
},
wantStatusCode: http.StatusNotFound,
},
{
name: "no workspace in context",
setupContext: func() *context.HandlerContext {
return &context.HandlerContext{
UserID: 1,
UserRole: "editor",
Workspace: nil,
}
},
wantStatusCode: http.StatusOK,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create request with context
req := httptest.NewRequest("GET", "/test", nil)
req = context.WithHandlerContext(req, tc.setupContext())
w := newMockResponseWriter()
// Create test handler
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
})
// Execute middleware
middleware.RequireWorkspaceAccess(next).ServeHTTP(w, req)
// Check status code
if w.statusCode != tc.wantStatusCode {
t.Errorf("status code = %v, want %v", w.statusCode, tc.wantStatusCode)
}
// Check if next handler was called when expected
if tc.wantStatusCode == http.StatusOK && !nextCalled {
t.Error("next handler was not called")
}
if tc.wantStatusCode != http.StatusOK && nextCalled {
t.Error("next handler was called when it shouldn't have been")
}
})
}
}

View File

@@ -0,0 +1,93 @@
package auth
import (
"fmt"
"novamd/internal/db"
"novamd/internal/models"
"time"
"github.com/google/uuid"
)
// SessionService manages user sessions in the database
type SessionService struct {
db db.SessionStore // Database store for sessions
jwtManager JWTManager // JWT Manager for token operations
}
// NewSessionService creates a new session service with the given database and JWT manager
func NewSessionService(db db.SessionStore, jwtManager JWTManager) *SessionService {
return &SessionService{
db: db,
jwtManager: jwtManager,
}
}
// CreateSession creates a new user session for a user with the given userID and role
func (s *SessionService) CreateSession(userID int, role string) (*models.Session, string, error) {
// Generate both access and refresh tokens
accessToken, err := s.jwtManager.GenerateAccessToken(userID, role)
if err != nil {
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
}
refreshToken, err := s.jwtManager.GenerateRefreshToken(userID, role)
if err != nil {
return nil, "", fmt.Errorf("failed to generate refresh token: %w", err)
}
// Validate the refresh token to get its expiry time
claims, err := s.jwtManager.ValidateToken(refreshToken)
if err != nil {
return nil, "", fmt.Errorf("failed to validate refresh token: %w", err)
}
// Create a new session record
session := &models.Session{
ID: uuid.New().String(),
UserID: userID,
RefreshToken: refreshToken,
ExpiresAt: claims.ExpiresAt.Time,
CreatedAt: time.Now(),
}
// Store the session
if err := s.db.CreateSession(session); err != nil {
return nil, "", err
}
return session, accessToken, nil
}
// RefreshSession creates a new access token using a refreshToken
func (s *SessionService) RefreshSession(refreshToken string) (string, error) {
// Get session from database first
session, err := s.db.GetSessionByRefreshToken(refreshToken)
if err != nil {
return "", fmt.Errorf("invalid session: %w", err)
}
// Validate the refresh token
claims, err := s.jwtManager.ValidateToken(refreshToken)
if err != nil {
return "", fmt.Errorf("invalid refresh token: %w", err)
}
// Double check that the claims match the session
if claims.UserID != session.UserID {
return "", fmt.Errorf("token does not match session")
}
// Generate a new access token
return s.jwtManager.GenerateAccessToken(claims.UserID, claims.Role)
}
// InvalidateSession removes a session with the given sessionID from the database
func (s *SessionService) InvalidateSession(sessionID string) error {
return s.db.DeleteSession(sessionID)
}
// CleanExpiredSessions removes all expired sessions from the database
func (s *SessionService) CleanExpiredSessions() error {
return s.db.CleanExpiredSessions()
}

Some files were not shown because too many files have changed in this diff Show More