mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-06 07:54:22 +00:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d74821f29 | |||
| 66415738b6 | |||
| dff66ab1b4 | |||
| 05933f8c22 | |||
| 68ad70b0b7 | |||
| d5d2792689 | |||
| 31d00681a1 | |||
| 4359267d55 | |||
| c07f19bbb3 | |||
| 325cedc235 | |||
| 453c022959 | |||
| de2a9364ab | |||
| cfa048b8de | |||
| 842513f8a5 | |||
| ae48761d34 | |||
| 8bed3614ee | |||
| 2a53be5a6e | |||
| af9ab42969 | |||
| d47b601447 | |||
| 1ddf93a8be | |||
| 6aa3fd6c65 | |||
| 9b4db528ca | |||
| f5d616fe00 | |||
| 51ed9e53a4 | |||
| 3fb40a8817 | |||
| 91489ca633 | |||
| fbb8fa3a60 | |||
| 4ddf1f570f | |||
| e8868dde39 | |||
| 32bd202d6f | |||
| 9ac047d440 | |||
| 1e7cd0934e | |||
| 9d81b1036d | |||
| 9f241271a7 | |||
| 8f2f8b30dd | |||
| 1150c4ba39 | |||
| ebdd7bd741 | |||
| b3ec4e136c | |||
| 807e96a76c | |||
| 2faefb6db5 | |||
| 435dce89d9 | |||
| 6cb5aec372 | |||
| 7396b57a5d | |||
| 53e52bfdb5 | |||
| de2c9a6d0c | |||
| 2fe642ac61 | |||
| 408746187e | |||
| e4510298ed | |||
| 5311d2e144 | |||
| 6a9461d928 | |||
| 93963b1867 | |||
| 52ffb17e2d | |||
| fb1c9a499f | |||
| f4c21edca0 | |||
| 1b58b693d0 | |||
| d11525732d | |||
| 03cdb133e7 | |||
| bac4702771 | |||
| f3f3cb7371 | |||
| d4c671caa7 | |||
| 29b35f6b91 | |||
| e275b45c86 | |||
| 77d9abb691 | |||
| 8cf850a62c | |||
| 5e2d434b4b | |||
| 148001be43 | |||
| 33bc28560f | |||
| 118591df62 | |||
| 7b1da94e8a | |||
| 9688b2d528 | |||
| ebf32e775c | |||
| dd3ea9f65f | |||
| 51751a5af6 | |||
| 0480c165ae | |||
| 24f877e50b | |||
| adf5287db2 | |||
| 0f6dcd3a60 | |||
| 7f8c40c3a2 | |||
| 64029615ea | |||
| 1a14c06be2 | |||
| 48f75b3839 | |||
| e56378f1f0 | |||
| 505b93ff09 | |||
| 8b8bfaa8c8 | |||
| 9581e32e06 | |||
| 771650d66e | |||
| 69afef15ec | |||
| 9cdbf9fec8 | |||
| fae628c02b | |||
| 927d1feb05 | |||
| c8cc854fd6 | |||
| dfd9544fba | |||
| 72680abdf4 | |||
| 46eeb18a31 | |||
| 34868c53eb | |||
| be0f97ab24 | |||
| ce245c980a | |||
| ea91d8d608 | |||
| 0b0a5253f0 | |||
| f8cd11c9ac | |||
| 0ed2813643 | |||
| 3c855fce21 |
34
.github/workflows/go-test.yml
vendored
Normal file
34
.github/workflows/go-test.yml
vendored
Normal 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
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
63
README.md
63
README.md
@@ -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.
|
||||
|
||||
772
frontend/package-lock.json → app/package-lock.json
generated
772
frontend/package-lock.json → app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
@@ -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>
|
||||
</>
|
||||
66
app/src/components/auth/LoginPage.jsx
Normal file
66
app/src/components/auth/LoginPage.jsx
Normal 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;
|
||||
@@ -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} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
111
app/src/components/editor/MarkdownPreview.jsx
Normal file
111
app/src/components/editor/MarkdownPreview.jsx
Normal 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;
|
||||
@@ -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();
|
||||
@@ -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}
|
||||
22
app/src/components/layout/Header.jsx
Normal file
22
app/src/components/layout/Header.jsx
Normal 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;
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
);
|
||||
55
app/src/components/modals/account/DeleteAccountModal.jsx
Normal file
55
app/src/components/modals/account/DeleteAccountModal.jsx
Normal 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;
|
||||
51
app/src/components/modals/account/EmailPasswordModal.jsx
Normal file
51
app/src/components/modals/account/EmailPasswordModal.jsx
Normal 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;
|
||||
@@ -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('');
|
||||
@@ -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 } =
|
||||
@@ -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('');
|
||||
76
app/src/components/modals/user/CreateUserModal.jsx
Normal file
76
app/src/components/modals/user/CreateUserModal.jsx
Normal 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;
|
||||
29
app/src/components/modals/user/DeleteUserModal.jsx
Normal file
29
app/src/components/modals/user/DeleteUserModal.jsx
Normal 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;
|
||||
105
app/src/components/modals/user/EditUserModal.jsx
Normal file
105
app/src/components/modals/user/EditUserModal.jsx
Normal 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;
|
||||
@@ -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 }) => {
|
||||
155
app/src/components/navigation/UserMenu.jsx
Normal file
155
app/src/components/navigation/UserMenu.jsx
Normal 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;
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
10
app/src/components/settings/AccordionControl.jsx
Normal file
10
app/src/components/settings/AccordionControl.jsx
Normal 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;
|
||||
248
app/src/components/settings/account/AccountSettings.jsx
Normal file
248
app/src/components/settings/account/AccountSettings.jsx
Normal 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;
|
||||
43
app/src/components/settings/account/DangerZoneSettings.jsx
Normal file
43
app/src/components/settings/account/DangerZoneSettings.jsx
Normal 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;
|
||||
23
app/src/components/settings/account/ProfileSettings.jsx
Normal file
23
app/src/components/settings/account/ProfileSettings.jsx
Normal 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;
|
||||
65
app/src/components/settings/account/SecuritySettings.jsx
Normal file
65
app/src/components/settings/account/SecuritySettings.jsx
Normal 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;
|
||||
44
app/src/components/settings/admin/AdminDashboard.jsx
Normal file
44
app/src/components/settings/admin/AdminDashboard.jsx
Normal 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;
|
||||
56
app/src/components/settings/admin/AdminStatsTab.jsx
Normal file
56
app/src/components/settings/admin/AdminStatsTab.jsx
Normal 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;
|
||||
162
app/src/components/settings/admin/AdminUsersTab.jsx
Normal file
162
app/src/components/settings/admin/AdminUsersTab.jsx
Normal 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;
|
||||
67
app/src/components/settings/admin/AdminWorkspacesTab.jsx
Normal file
67
app/src/components/settings/admin/AdminWorkspacesTab.jsx
Normal 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;
|
||||
@@ -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();
|
||||
@@ -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 } =
|
||||
36
app/src/components/settings/workspace/EditorSettings.jsx
Normal file
36
app/src/components/settings/workspace/EditorSettings.jsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
120
app/src/contexts/AuthContext.jsx
Normal file
120
app/src/contexts/AuthContext.jsx
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
48
app/src/hooks/useAdminData.js
Normal file
48
app/src/hooks/useAdminData.js
Normal 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 };
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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',
|
||||
@@ -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',
|
||||
@@ -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);
|
||||
}
|
||||
71
app/src/hooks/useProfileSettings.js
Normal file
71
app/src/hooks/useProfileSettings.js
Normal 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,
|
||||
};
|
||||
}
|
||||
79
app/src/hooks/useUserAdmin.js
Normal file
79
app/src/hooks/useUserAdmin.js
Normal 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,
|
||||
};
|
||||
};
|
||||
49
app/src/services/adminApi.js
Normal file
49
app/src/services/adminApi.js
Normal 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
177
app/src/services/api.js
Normal 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();
|
||||
};
|
||||
97
app/src/services/authApi.js
Normal file
97
app/src/services/authApi.js
Normal 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();
|
||||
};
|
||||
@@ -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',
|
||||
]);
|
||||
10
app/src/utils/formatBytes.js
Normal file
10
app/src/utils/formatBytes.js
Normal 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]}`;
|
||||
};
|
||||
177
app/src/utils/remarkWikiLinks.js
Normal file
177
app/src/utils/remarkWikiLinks.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(¤tVersion)
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
``
|
||||
);
|
||||
} 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;
|
||||
@@ -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;
|
||||
@@ -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
33
server/cmd/server/main.go
Normal 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
50
server/gendocs.sh
Executable 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"
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
97
server/internal/api/routes.go
Normal file
97
server/internal/api/routes.go
Normal 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
223
server/internal/app/app.go
Normal 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
134
server/internal/auth/jwt.go
Normal 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)
|
||||
}
|
||||
221
server/internal/auth/jwt_test.go
Normal file
221
server/internal/auth/jwt_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
104
server/internal/auth/middleware.go
Normal file
104
server/internal/auth/middleware.go
Normal 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)
|
||||
})
|
||||
}
|
||||
294
server/internal/auth/middleware_test.go
Normal file
294
server/internal/auth/middleware_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
93
server/internal/auth/session.go
Normal file
93
server/internal/auth/session.go
Normal 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
Reference in New Issue
Block a user