app template

This commit is contained in:
2026-04-01 15:16:12 +02:00
parent 724ee77710
commit 9a429b16a2
74 changed files with 6041 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
# MyApp Development Instructions
A Go + Vue.js full-stack web application template with authentication, user management, i18n, and Docker.
## Project Overview
- **Architecture**: Monorepo with separate backend/frontend in Docker containers
- **Backend**: Go 1.25 + Gin framework + GORM + MariaDB
- **Frontend**: Vue 3 + Vite + Pinia + vue-i18n
- **Development**: Docker Compose with live-reload (Air for Go, Vite HMR for Vue)
## Backend Architecture (`backend/`)
### Module Structure
Each domain module follows this pattern:
```
module/
model.go # GORM entities + MigrateStructure(db)
handlers.go # HTTP handlers (Gin controllers)
routes.go # Route registration: RegisterRoutes(r *gin.RouterGroup)
*_test.go # Tests with setupTestEnvironment(t)
```
**Key conventions:**
- **Entry point**: `cmd/main.go` registers all modules via `RegisterRoutes(protected)`
- **Models**: Domain-specific models per module
- **Database**: Shared `database.DB` instance, use `MigrateStructure(db)` per module
- **Configuration**: `config.Cfg` loaded from env vars (see `config/config.go`)
- **Environment**: `ENVIRONMENT=test|development|production`
- **Database type**: `DATABASE_TYPE=mysql|sqlite`
### Testing Requirements
- **NEVER** create test files with `main()` functions
- **ALWAYS** use `_test.go` suffix
- **ALWAYS** call `setupTestEnvironment(t)` at start of each test:
```go
func setupTestEnvironment(t *testing.T) {
original := os.Getenv("ENVIRONMENT")
os.Setenv("ENVIRONMENT", "test")
t.Cleanup(func() { os.Setenv("ENVIRONMENT", original) })
}
```
- Use `testutils.SetupTestDB()` for database tests (creates SQLite test DB)
### API Patterns
- Protected routes under `/api` prefix require JWT authentication
- Use `c.JSON(http.StatusBadRequest, gin.H{"error": "message"})` for error responses
- Route registration example:
```go
func RegisterRoutes(r *gin.RouterGroup) {
group := r.Group("/items")
group.GET("", ListItems)
group.POST("", CreateItem)
group.GET("/:id", GetItem)
group.PUT("/:id", UpdateItem)
group.DELETE("/:id", DeleteItem)
}
```
## Frontend Architecture (`frontend/`)
### Component Structure
- **Views**: `src/views/` - Page-level components
- **Components**: `src/components/` - Reusable components (Menu, forms)
- **Utils**: `src/utils/api.js` - Axios instance with JWT interceptor
- **Locales**: `src/locales/de` and `src/locales/en` - i18n translations (plain JS objects, no `.js` extension)
- **Store**: Pinia stores in `src/stores/`
### API Communication
- Use `API.get()`, `API.post()`, `API.put()`, `API.delete()` from `utils/api.js` (auto-adds auth headers)
- Base URL configured via `VITE_API_URL` env var
### Translation Pattern
Add to both `locales/de` and `locales/en`:
```js
export default {
myModule: {
title: 'My Module', // EN
createButton: 'Create'
}
}
```
## Docker Development Workflow
### Container Names & Ports
- `myapp-backend-dev`: Go API at http://localhost:8180 (Air live-reload)
- `myapp-frontend-dev`: Vue at http://localhost:5173 (Vite HMR)
- `myapp-mariadb-dev`: MariaDB at localhost:3306
- `myapp-phpmyadmin-dev`: http://localhost:8082
### Start Development
```bash
cd docker/
docker-compose -f docker-compose.dev.yml up -d
```
### View Logs
```bash
docker logs myapp-backend-dev --tail=50
docker logs myapp-frontend-dev --tail=20
```
## Testing Commands
### Backend Tests
```bash
cd backend/
go test -v ./items/
go test -v ./user/
go test ./...
```
## Critical Rules
1. **NEVER** write example/demo code — only production code
2. **NEVER** create test files with `main()` — use `_test.go`
3. **ALWAYS** use TDD: write failing test first, then implement
4. **ALWAYS** use KISS principle: simplest solution that works
5. **ALWAYS** add translations to both DE and EN locales
6. **ALWAYS** use global CSS (`main.css`) for consistent styling — avoid local scoped styles for common UI patterns
7. **ALWAYS** check `docker ps` before assuming containers are running
## File-Specific Instructions
Load additional instructions for specific file types:
- Go files: See `.github/instructions/go.instructions.md`
- Vue files: See `.github/instructions/vue.instructions.md`
+298
View File
@@ -0,0 +1,298 @@
---
description: 'Instructions for writing Go code following idiomatic Go practices and community standards'
applyTo: '**/*.go,**/go.mod,**/go.sum'
---
# Go Development Instructions
Follow idiomatic Go practices and community standards when writing Go code. These instructions are based on [Effective Go](https://go.dev/doc/effective_go), [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments), and [Google's Go Style Guide](https://google.github.io/styleguide/go/).
## General Instructions
- Write simple, clear, and idiomatic Go code
- Favor clarity and simplicity over cleverness
- Follow the principle of least surprise
- Keep the happy path left-aligned (minimize indentation)
- Return early to reduce nesting
- Make the zero value useful
- Document exported types, functions, methods, and packages
- Use Go modules for dependency management
- You write tests ONLY in _test.go files.
- You will NEVER create files for testing with a main() function!
## Naming Conventions
### Packages
- Use lowercase, single-word package names
- Avoid underscores, hyphens, or mixedCaps
- Choose names that describe what the package provides, not what it contains
- Avoid generic names like `util`, `common`, or `base`
- Package names should be singular, not plural
### Variables and Functions
- Use mixedCaps or MixedCaps (camelCase) rather than underscores
- Keep names short but descriptive
- Use single-letter variables only for very short scopes (like loop indices)
- Exported names start with a capital letter
- Unexported names start with a lowercase letter
- Avoid stuttering (e.g., avoid `http.HTTPServer`, prefer `http.Server`)
### Interfaces
- Name interfaces with -er suffix when possible (e.g., `Reader`, `Writer`, `Formatter`)
- Single-method interfaces should be named after the method (e.g., `Read``Reader`)
- Keep interfaces small and focused
### Constants
- Use MixedCaps for exported constants
- Use mixedCaps for unexported constants
- Group related constants using `const` blocks
- Consider using typed constants for better type safety
## Code Style and Formatting
### Formatting
- Always use `gofmt` to format code
- Use `goimports` to manage imports automatically
- Keep line length reasonable (no hard limit, but consider readability)
- Add blank lines to separate logical groups of code
### Comments
- Write comments in complete sentences
- Start sentences with the name of the thing being described
- Package comments should start with "Package [name]"
- Use line comments (`//`) for most comments
- Use block comments (`/* */`) sparingly, mainly for package documentation
- Document why, not what, unless the what is complex
### Error Handling
- Check errors immediately after the function call
- Don't ignore errors using `_` unless you have a good reason (document why)
- Wrap errors with context using `fmt.Errorf` with `%w` verb
- Create custom error types when you need to check for specific errors
- Place error returns as the last return value
- Name error variables `err`
- Keep error messages lowercase and don't end with punctuation
## Architecture and Project Structure
### Package Organization
- Follow standard Go project layout conventions
- Keep `main` packages in `cmd/` directory
- Put reusable packages in `pkg/` or `internal/`
- Use `internal/` for packages that shouldn't be imported by external projects
- Group related functionality into packages
- Avoid circular dependencies
### Dependency Management
- Use Go modules (`go.mod` and `go.sum`)
- Keep dependencies minimal
- Regularly update dependencies for security patches
- Use `go mod tidy` to clean up unused dependencies
- Vendor dependencies only when necessary
## Type Safety and Language Features
### Type Definitions
- Define types to add meaning and type safety
- Use struct tags for JSON, XML, database mappings
- Prefer explicit type conversions
- Use type assertions carefully and check the second return value
### Pointers vs Values
- Use pointers for large structs or when you need to modify the receiver
- Use values for small structs and when immutability is desired
- Be consistent within a type's method set
- Consider the zero value when choosing pointer vs value receivers
### Interfaces and Composition
- Accept interfaces, return concrete types
- Keep interfaces small (1-3 methods is ideal)
- Use embedding for composition
- Define interfaces close to where they're used, not where they're implemented
- Don't export interfaces unless necessary
## Concurrency
### Goroutines
- Don't create goroutines in libraries; let the caller control concurrency
- Always know how a goroutine will exit
- Use `sync.WaitGroup` or channels to wait for goroutines
- Avoid goroutine leaks by ensuring cleanup
### Channels
- Use channels to communicate between goroutines
- Don't communicate by sharing memory; share memory by communicating
- Close channels from the sender side, not the receiver
- Use buffered channels when you know the capacity
- Use `select` for non-blocking operations
### Synchronization
- Use `sync.Mutex` for protecting shared state
- Keep critical sections small
- Use `sync.RWMutex` when you have many readers
- Prefer channels over mutexes when possible
- Use `sync.Once` for one-time initialization
## Error Handling Patterns
### Creating Errors
- Use `errors.New` for simple static errors
- Use `fmt.Errorf` for dynamic errors
- Create custom error types for domain-specific errors
- Export error variables for sentinel errors
- Use `errors.Is` and `errors.As` for error checking
### Error Propagation
- Add context when propagating errors up the stack
- Don't log and return errors (choose one)
- Handle errors at the appropriate level
- Consider using structured errors for better debugging
## API Design
### HTTP Handlers
- Use `http.HandlerFunc` for simple handlers
- Implement `http.Handler` for handlers that need state
- Use middleware for cross-cutting concerns
- Set appropriate status codes and headers
- Handle errors gracefully and return appropriate error responses
### JSON APIs
- Use struct tags to control JSON marshaling
- Validate input data
- Use pointers for optional fields
- Consider using `json.RawMessage` for delayed parsing
- Handle JSON errors appropriately
## Performance Optimization
### Memory Management
- Minimize allocations in hot paths
- Reuse objects when possible (consider `sync.Pool`)
- Use value receivers for small structs
- Preallocate slices when size is known
- Avoid unnecessary string conversions
### Profiling
- Use built-in profiling tools (`pprof`)
- Benchmark critical code paths
- Profile before optimizing
- Focus on algorithmic improvements first
- Consider using `testing.B` for benchmarks
## Testing
### Test Organization
- Keep tests in the same package (white-box testing)
- Use `_test` package suffix for black-box testing
- Name test files with `_test.go` suffix
- Place test files next to the code they test
### Writing Tests
- Use table-driven tests for multiple test cases
- Name tests descriptively using `Test_functionName_scenario`
- Use subtests with `t.Run` for better organization
- Test both success and error cases
- Include benchmarks when performance matters: `BenchmarkFunctionName`
- Use `testify` or similar libraries sparingly
### Test Helpers
- Mark helper functions with `t.Helper()`
- Create test fixtures for complex setup
- Use `testing.TB` interface for functions used in tests and benchmarks
- Clean up resources using `t.Cleanup()`
## Security Best Practices
### Input Validation
- Validate all external input
- Use strong typing to prevent invalid states
- Sanitize data before using in SQL queries
- Be careful with file paths from user input
- Validate and escape data for different contexts (HTML, SQL, shell)
### Cryptography
- Use standard library crypto packages
- Don't implement your own cryptography
- Use crypto/rand for random number generation
- Store passwords using bcrypt or similar
- Use TLS for network communication
## Documentation
### Code Documentation
- Document all exported symbols
- Start documentation with the symbol name
- Use examples in documentation when helpful
- Keep documentation close to code
- Update documentation when code changes
### README and Documentation Files
- Include clear setup instructions
- Document dependencies and requirements
- Provide usage examples
- Document configuration options
- Include troubleshooting section
## Tools and Development Workflow
### Essential Tools
- `go fmt`: Format code
- `go vet`: Find suspicious constructs
- `golint` or `golangci-lint`: Additional linting
- `go test`: Run tests
- `go mod`: Manage dependencies
- `go generate`: Code generation
### Development Practices
- Always use Test-Driven Development (TDD)
- Write tests before any implementation
- No implementation code until tests exist and are reviewed
- Run tests before committing
- Use pre-commit hooks for formatting and linting
- Keep commits focused and atomic
- Write meaningful commit messages
- Review diffs before committing
## Common Pitfalls to Avoid
- Not checking errors
- Ignoring race conditions
- Creating goroutine leaks
- Not using defer for cleanup
- Modifying maps concurrently
- Not understanding nil interfaces vs nil pointers
- Forgetting to close resources (files, connections)
- Using global variables unnecessarily
- Over-using empty interfaces (`interface{}`)
- Not considering the zero value of types
+398
View File
@@ -0,0 +1,398 @@
---
description: 'Instructions for writing Vue 3 components following project conventions and best practices'
applyTo: '**/*.vue, **/*.ts, **/*.js, **/*.scss'
---
# Vue 3 Development Instructions
Follow Vue 3 best practices and project-specific conventions when writing components.
## Component Structure
### Standard Component Layout
```vue
<template>
<!-- HTML template -->
</template>
<style scoped>
/* Component-specific styles */
</style>
<script>
// Component logic
</script>
```
**Order matters**: Template, Style, Script (as seen throughout the codebase)
### Options API Pattern (Primary)
Use Options API for consistency with existing codebase:
```vue
<script>
export default {
name: "ComponentName",
props: ["id"],
data() {
return {
items: [],
isLoading: false
}
},
async created() {
// Initialization logic
},
methods: {
async methodName() {
// Use const/let, never var
const response = await API.get('/api/endpoint')
this.items = response.data
}
}
}
</script>
```
## API Communication
### Using the API Utility
Always use `API` from `utils/api.js` - it handles authentication automatically:
```js
import API from '../utils/api'
// In methods:
const response = await API.get(`/api/characters/${this.id}`)
const data = await API.post('/api/characters', character)
```
**Never** manually add Authorization headers - the interceptor handles this.
### Environment Variables
API base URL from Vite:
```js
import API from '../utils/api'
// API uses: import.meta.env.VITE_API_URL || 'http://localhost:8180'
```
### API Configuration (`utils/api.js`)
Standard Axios instance with interceptors:
```js
import axios from 'axios'
const API = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8180'
})
// Request interceptor - adds auth token
API.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor - handles 401
API.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
}
return Promise.reject(error)
}
)
export default API
```
### Error Handling Pattern
```js
try {
const response = await API.get(`/api/endpoint`)
this.data = response.data
} catch (error) {
console.error('Failed to load data:', error)
const errorMsg = error.response?.data?.error ?? error.message
alert(`${this.$t('errors.loadFailed')}: ${errorMsg}`)
}
```
## Internationalization (i18n)
### Using Translations
```vue
<template>
<h2>{{ $t('char') }}: {{ character.name }}</h2>
<button>{{ $t('export.exportPDF') }}</button>
</template>
```
### Adding New Translations
**ALWAYS** add to both `src/locales/de` and `src/locales/en`:
```js
// src/locales/de
export default {
export: {
selectTemplate: 'Vorlage wählen',
exportPDF: 'PDF Export'
}
}
// src/locales/en
export default {
export: {
selectTemplate: 'Select Template',
exportPDF: 'Export PDF'
}
}
```
**Note**: Locale files use `.js` extension and export objects, not JSON.
## Modal Dialog Pattern
### Standard Modal Structure
```vue
<template>
<!-- Trigger -->
<button @click="showDialog = true">Open</button>
<!-- Modal -->
<div v-if="showDialog" class="modal-overlay" @click.self="showDialog = false">
<div class="modal-content">
<div class="modal-header">
<h3>{{ $t('modal.title') }}</h3>
<button @click="showDialog = false" class="close-button">&times;</button>
</div>
<div class="modal-body">
<!-- Content -->
</div>
<div class="modal-footer">
<button @click="showDialog = false" class="btn-cancel">{{ $t('cancel') }}</button>
<button @click="handleSubmit" class="btn-primary">{{ $t('submit') }}</button>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
showDialog: false
}
}
}
</script>
```
**Key conventions:**
- Use `@click.self` on overlay to close on outside click
- Include close button (×) in header
- Separate header, body, footer sections
## Component Communication
### Props (Parent → Child)
```vue
<script>
export default {
props: {
character: Object,
id: [String, Number]
}
}
</script>
```
### Events (Child → Parent)
```vue
<template>
<button @click="notifyParent">Update</button>
</template>
<script>
export default {
methods: {
notifyParent() {
this.$emit('character-updated', this.character)
}
}
}
</script>
<!-- Parent component -->
<template>
<ChildComponent @character-updated="refreshCharacter" />
</template>
```
## State Management
### Pinia Store Pattern (`stores/`)
For global state (language, auth, etc.):
```js
import { defineStore } from 'pinia'
export const useLanguageStore = defineStore('language', {
state: () => ({
currentLanguage: localStorage.getItem('language') || 'de'
}),
actions: {
setLanguage(lang) {
this.currentLanguage = lang
localStorage.setItem('language', lang)
}
}
})
```
### Loading States
Always show feedback for async operations:
```vue
<template>
<button @click="submit" :disabled="isLoading">
<span v-if="!isLoading">{{ $t('submit') }}</span>
<span v-else>{{ $t('loading') }}</span>
</button>
</template>
<script>
export default {
data() {
return { isLoading: false }
},
methods: {
async submit() {
this.isLoading = true
try {
await API.post('/api/endpoint', this.data)
} finally {
this.isLoading = false
}
}
}
}
</script>
```
### Disabling Form Elements During Loading
```vue
<select v-model="selected" :disabled="isLoading">
<input type="checkbox" v-model="option" :disabled="isLoading">
```
## Form Validation
### Validation Pattern
```js
methods: {
validateForm() {
if (!this.selectedTemplate) {
alert(this.$t('export.pleaseSelectTemplate'))
return false
}
return true
},
async submit() {
if (!this.validateForm()) return
this.isLoading = true
try {
await API.post('/api/endpoint', this.data)
} finally {
this.isLoading = false
}
}
}
```
## Browser Compatibility
### Popup Blocker Workaround
Open windows **synchronously** before async operations:
```js
async exportToPDF() {
// Open window FIRST (synchronously)
const pdfWindow = window.open('', '_blank')
if (!pdfWindow) {
alert(this.$t('export.popupBlocked'))
return
}
// Show loading page
pdfWindow.document.write('<html>...</html>')
// Then do async work
const response = await API.get('/api/pdf/export')
// Update window with result
pdfWindow.location.href = url
}
```
**Critical**: `window.open()` must be called synchronously in the click handler, not after `await`.
## Common Patterns
### Dynamic Component Loading
```vue
<template>
<component :is="currentView" :character="character" @character-updated="refresh"/>
</template>
<script>
import ViewA from './ViewA.vue'
import ViewB from './ViewB.vue'
export default {
components: { ViewA, ViewB },
data() {
return { currentView: 'ViewA' }
}
}
</script>
```
### Conditional Rendering
- Use `v-if` for elements that toggle rarely
- Use `v-show` for frequent toggles
- Use `v-for` with `:key` attribute always
### Event Modifiers
- `@click.self` - only trigger if clicked element itself
- `@submit.prevent` - prevent form submission
- `@keyup.enter` - keyboard event handling
## Best Practices
1. **Use global CSS definition** to ensure consistent styling
2. **Always use scoped styles** to avoid CSS conflicts
3. **Name components with PascalCase** (e.g., `CharacterDetails.vue`)
4. **Use `const` by default, `let` when needed** - never use `var`
5. **Use optional chaining `?.` and nullish coalescing `??`** for safe property access
6. **Handle errors gracefully** with user-friendly messages
7. **Keep template logic simple** - move complex logic to methods
8. **Clean up resources** in `beforeUnmount` if needed
9. **Test with actual API** running in Docker container
10. **Check HMR reload** - view logs: `docker logs bamort-frontend-dev`
## Anti-Patterns to Avoid
❌ Don't use `v-if` and `v-for` on the same element
❌ Don't mutate props directly
❌ Don't use `var` - use `const` or `let`
❌ Don't use `==` - always use `===` for comparisons
❌ Don't forget to handle loading and error states
❌ Don't use inline styles - use scoped CSS
❌ Don't call API methods in template expressions
❌ Don't forget translations - add to both DE and EN
+231
View File
@@ -0,0 +1,231 @@
# MyApp Template
A full-stack web application template based on **Go + Vue.js**, providing authentication, user management, i18n, and a generic CRUD example module.
## Technology Stack
| Layer | Technology |
|-------|-----------|
| Backend | Go 1.25 + Gin + GORM |
| Database | MariaDB (prod) / SQLite (test) |
| Frontend | Vue 3 + Vite + Pinia + vue-i18n |
| HTTP Client | Axios |
| Auth | JWT tokens |
| Dev Reload | Air (Go) + Vite HMR (Vue) |
| Containers | Docker + Docker Compose |
## Project Structure
```
template/
├── backend/
│ ├── cmd/main.go # Entry point
│ ├── config/ # Environment configuration
│ ├── database/ # GORM + DB setup + migrations
│ ├── logger/ # Structured logger
│ ├── mail/ # SMTP email client
│ ├── router/ # Gin setup + CORS + auth middleware
│ ├── testutils/ # Test environment helpers
│ ├── user/ # Auth, JWT, user CRUD, password reset
│ ├── appsystem/ # Version + health endpoints
│ └── items/ # Example generic CRUD module
├── frontend/
│ ├── src/
│ │ ├── main.js # App bootstrap (Pinia, Router, i18n)
│ │ ├── App.vue # Root component
│ │ ├── router/ # Vue Router with auth guards
│ │ ├── stores/ # Pinia stores (userStore, languageStore)
│ │ ├── utils/ # api.js, auth.js, dateUtils.js
│ │ ├── locales/ # DE + EN translations (plain JS)
│ │ ├── components/ # Menu, Login/Register forms
│ │ ├── views/ # Page-level view components
│ │ └── assets/ # main.css + base.css (design system)
│ └── nginx.conf # SPA routing for production
└── docker/
├── Dockerfile.backend
├── Dockerfile.backend.dev
├── Dockerfile.frontend
├── Dockerfile.frontend.dev
├── docker-compose.yml # Production
└── docker-compose.dev.yml # Development
```
## How to Use This Template
### 1. Copy and rename the project
```bash
cp -r template/ my-project/
cd my-project/
```
### 2. Update the Go module name
Replace `myapp` with your module name in all Go files:
```bash
# Update go.mod
sed -i 's|myapp|github.com/yourorg/yourproject|g' backend/go.mod
# Update all imports
find backend/ -name "*.go" -exec sed -i 's|myapp|github.com/yourorg/yourproject|g' {} \;
```
### 3. Configure environment variables
Copy and edit the environment files:
```bash
cp backend/.env.example backend/.env
# Edit backend/.env with your database credentials, JWT secret, SMTP config
# For frontend:
# Edit frontend/.env or set VITE_API_URL in docker-compose
```
Key variables in `backend/.env`:
```env
SERVER_PORT=8180
DATABASE_TYPE=mysql
DATABASE_URL=user:password@tcp(localhost:3306)/myapp?charset=utf8mb4&parseTime=True&loc=Local
JWT_SECRET=your_secure_random_secret_here
FRONTEND_URL=http://localhost:5173
SMTP_HOST=smtp.example.com
SMTP_PORT=465
SMTP_USER=user@example.com
SMTP_PASSWORD=yourpassword
SMTP_FROM=noreply@example.com
```
### 4. Start development environment
```bash
cd docker/
docker-compose -f docker-compose.dev.yml up -d
```
Services:
- **Backend API**: http://localhost:8180
- **Frontend**: http://localhost:5173
- **MariaDB**: localhost:3306
- **phpMyAdmin**: http://localhost:8082
### 5. Start production environment
```bash
cd docker/
docker-compose up -d
```
---
## Adding a New Domain Module
### Backend Module Pattern
Create a new directory, e.g. `backend/products/`:
```
products/
model.go # GORM struct + MigrateStructure()
handlers.go # Gin handlers
routes.go # RegisterRoutes(r *gin.RouterGroup)
*_test.go # Tests with setupTestEnvironment(t)
```
Register in `cmd/main.go`:
```go
import "myapp/products"
// In main() after ConnectDatabase():
products.MigrateStructure(database.DB)
// After router setup:
products.RegisterRoutes(protected)
```
### Frontend Module Pattern
1. Add routes to `src/router/index.js`
2. Create views in `src/views/`
3. Add i18n keys to both `src/locales/de` and `src/locales/en`
4. Add menu items to `src/components/Menu.vue`
---
## API Endpoints (Built-in)
### Public (no auth)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/register` | Register new user |
| POST | `/login` | Login, returns JWT token |
| POST | `/password-reset/request` | Request password reset email |
| POST | `/password-reset/validate` | Validate reset token |
| POST | `/password-reset/reset` | Set new password |
| GET | `/api/public/version` | Frontend version info |
| GET | `/api/public/systeminfo` | System info |
### Protected (requires `Authorization: Bearer <token>`)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/user/profile` | Get current user profile |
| PUT | `/api/user/display-name` | Update display name |
| PUT | `/api/user/email` | Update email |
| PUT | `/api/user/password` | Change password |
| PUT | `/api/user/language` | Set preferred language |
| GET | `/api/items` | List current user's items |
| POST | `/api/items` | Create item |
| GET | `/api/items/:id` | Get item |
| PUT | `/api/items/:id` | Update item |
| DELETE | `/api/items/:id` | Delete item |
### Admin only (`role = admin`)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/users` | List all users |
| GET | `/api/users/:id` | Get user |
| PUT | `/api/users/:id/role` | Change user role |
| PUT | `/api/users/:id/password` | Change user password |
| DELETE | `/api/users/:id` | Delete user |
---
## Running Tests
```bash
cd backend/
go test ./...
# Run specific module tests
go test -v ./items/
go test -v ./user/
```
Tests use an in-memory SQLite database. No external services required.
---
## User Roles
| Role | Description |
|------|-------------|
| `standard` | Default role for registered users |
| `maintainer` | Elevated privileges (define in your domain logic) |
| `admin` | Full access including user management |
---
## Customization Checklist
- [ ] Replace `myapp` module name throughout backend
- [ ] Update `frontend/package.json` name field
- [ ] Set strong `JWT_SECRET` in production
- [ ] Configure SMTP for password reset emails
- [ ] Update `FRONTEND_URL` in docker-compose for CORS
- [ ] Replace placeholder text in `HelpView.vue` and locales
- [ ] Update i18n keys in `src/locales/de` and `src/locales/en`
- [ ] Replace `items` module with your domain models
- [ ] Update `Menu.vue` with your application's navigation
- [ ] Customize `LandingView.vue` for your application
- [ ] Set production domain in `docker-compose.yml`
+13
View File
@@ -0,0 +1,13 @@
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/server ./cmd/main.go"
bin = "./tmp/server"
delay = 1000
[log]
time = false
[misc]
clean_on_exit = true
+5
View File
@@ -0,0 +1,5 @@
tmp/
testdata/*.db
*.db
coverage.out
server
+13
View File
@@ -0,0 +1,13 @@
package appsystem
import "github.com/gin-gonic/gin"
// Versionsinfo handles GET /api/public/version.
func Versionsinfo(c *gin.Context) {
c.JSON(200, GetInfo())
}
// SystemInfo handles GET /api/public/systeminfo.
func SystemInfo(c *gin.Context) {
c.JSON(200, GetInfo2())
}
+16
View File
@@ -0,0 +1,16 @@
package appsystem
import "github.com/gin-gonic/gin"
// RegisterRoutes mounts appsystem endpoints on the protected /api group.
func RegisterRoutes(r *gin.RouterGroup) {
r.GET("/version", Versionsinfo)
r.GET("/systeminfo", SystemInfo)
}
// RegisterPublicRoutes mounts version/systeminfo as unauthenticated endpoints.
func RegisterPublicRoutes(r *gin.Engine) {
public := r.Group("/api/public")
public.GET("/version", Versionsinfo)
public.GET("/systeminfo", SystemInfo)
}
+50
View File
@@ -0,0 +1,50 @@
package appsystem
import "myapp/database"
// Version is the application version string.
// Update this when cutting a release.
const Version = "0.1.0"
// GitCommit is injected at build time via -ldflags.
var GitCommit = "unknown"
// Info carries version information returned by the public version endpoint.
type Info struct {
Version string `json:"version"`
GitCommit string `json:"gitCommit"`
}
// Info2 extends Info with live usage counters for the system-info endpoint.
type Info2 struct {
Version string `json:"version"`
GitCommit string `json:"gitCommit"`
UserCount int64 `json:"userCount"`
DbVersion string `json:"dbVersion"`
}
// GetInfo returns the current version details.
func GetInfo() Info {
return Info{Version: Version, GitCommit: GitCommit}
}
// GetInfo2 returns version details plus live database statistics.
func GetInfo2() Info2 {
var userCount int64
if database.DB != nil {
// Replace "users" with your actual users table name if different.
database.DB.Raw("SELECT COUNT(*) FROM users").Scan(&userCount)
}
var dbVersion string
if database.DB != nil {
database.DB.Raw("SELECT VERSION()").Scan(&dbVersion)
}
return Info2{
Version: Version,
GitCommit: GitCommit,
UserCount: userCount,
DbVersion: dbVersion,
}
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"myapp/appsystem"
"myapp/config"
"myapp/database"
"myapp/items"
"myapp/logger"
"myapp/router"
"myapp/user"
"github.com/gin-gonic/gin"
)
// @title MyApp API
// @version 1
// @description REST API generated from the Go+Vue template.
// @host localhost:8180
// @BasePath /
func main() {
cfg := config.Cfg
// Configure logger
logger.SetDebugMode(cfg.DebugMode)
switch cfg.LogLevel {
case "DEBUG":
logger.SetMinLogLevel(logger.DEBUG)
case "WARN":
logger.SetMinLogLevel(logger.WARN)
case "ERROR":
logger.SetMinLogLevel(logger.ERROR)
default:
logger.SetMinLogLevel(logger.INFO)
}
logger.Info("MyApp starting...")
logger.Info("Environment: %s", cfg.Environment)
logger.Info("Server port: %s", cfg.ServerPort)
if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode)
} else {
gin.SetMode(gin.DebugMode)
}
// Connect to database and run migrations
database.ConnectDatabase()
// Run module-level migrations
if err := user.MigrateStructure(); err != nil {
logger.Error("User migration failed: %s", err.Error())
}
if err := items.MigrateStructure(); err != nil {
logger.Error("Items migration failed: %s", err.Error())
}
r := gin.Default()
router.SetupGin(r)
// Protected routes (require authentication)
protected := router.BaseRouterGrp(r)
user.RegisterRoutes(protected)
items.RegisterRoutes(protected)
appsystem.RegisterRoutes(protected)
// Public routes (no authentication required)
appsystem.RegisterPublicRoutes(r)
logger.Info("Listening on %s", cfg.GetServerAddress())
if err := r.Run(cfg.GetServerAddress()); err != nil {
logger.Error("Server failed: %s", err.Error())
}
}
+139
View File
@@ -0,0 +1,139 @@
package config
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
// Config holds all application configuration.
type Config struct {
ServerPort string
DatabaseURL string
DatabaseType string
DebugMode bool
LogLevel string
Environment string
DevTesting string // "yes" or "no"
FrontendURL string // for CORS
// Mail configuration
MailHost string
MailPort int
MailUsername string
MailPassword string
MailFrom string
}
// Cfg is the global configuration variable, loaded once at startup.
var Cfg *Config
func init() {
Cfg = LoadConfig()
}
func defaultConfig() *Config {
return &Config{
ServerPort: "8180",
DatabaseURL: "",
DatabaseType: "mysql",
DebugMode: false,
LogLevel: "INFO",
Environment: "production",
DevTesting: "no",
FrontendURL: "http://localhost:5173",
MailHost: "",
MailPort: 465,
MailUsername: "",
MailPassword: "",
MailFrom: "",
}
}
// LoadConfig reads configuration from .env file and environment variable overrides.
func LoadConfig() *Config {
loadEnvFile()
cfg := defaultConfig()
if port := os.Getenv("SERVER_PORT"); port != "" {
cfg.ServerPort = port
}
if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" {
cfg.DatabaseURL = dbURL
}
if dbType := os.Getenv("DATABASE_TYPE"); dbType != "" {
cfg.DatabaseType = dbType
}
if debug := os.Getenv("DEBUG"); strings.ToLower(debug) == "true" || debug == "1" {
cfg.DebugMode = true
}
if level := os.Getenv("LOG_LEVEL"); level != "" {
cfg.LogLevel = strings.ToUpper(level)
}
if env := os.Getenv("ENVIRONMENT"); env != "" {
cfg.Environment = env
}
if dt := os.Getenv("DEV_TESTING"); dt != "" {
cfg.DevTesting = dt
}
if frontendURL := os.Getenv("FRONTEND_URL"); frontendURL != "" {
cfg.FrontendURL = frontendURL
}
if host := os.Getenv("MAIL_HOST"); host != "" {
cfg.MailHost = host
}
if port := os.Getenv("MAIL_PORT"); port != "" {
if p, err := strconv.Atoi(port); err == nil {
cfg.MailPort = p
}
}
if user := os.Getenv("MAIL_USERNAME"); user != "" {
cfg.MailUsername = user
}
if pass := os.Getenv("MAIL_PASSWORD"); pass != "" {
cfg.MailPassword = pass
}
if from := os.Getenv("MAIL_FROM"); from != "" {
cfg.MailFrom = from
}
return cfg
}
// IsProduction returns true when environment is "production".
func (c *Config) IsProduction() bool {
return strings.ToLower(c.Environment) == "production"
}
// GetServerAddress returns the address string for http.Server.
func (c *Config) GetServerAddress() string {
return fmt.Sprintf(":%s", c.ServerPort)
}
// loadEnvFile reads key=value pairs from .env then .env.local files into the process environment.
func loadEnvFile() {
for _, filename := range []string{".env", ".env.local"} {
f, err := os.Open(filename)
if err != nil {
continue
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if os.Getenv(key) == "" {
os.Setenv(key, val)
}
}
}
}
+79
View File
@@ -0,0 +1,79 @@
package database
import (
"myapp/config"
"myapp/logger"
"path/filepath"
"runtime"
"log"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// DB is the global database connection used by all packages.
var DB *gorm.DB
// getBackendDir returns the absolute path to the backend root directory.
func getBackendDir() string {
_, filename, _, _ := runtime.Caller(0)
return filepath.Dir(filepath.Dir(filename))
}
// PreparedTestDB is the path to the snapshot SQLite database used in tests.
var PreparedTestDB = filepath.Join(getBackendDir(), "testdata", "prepared_test_data.db")
// ConnectDatabase selects the appropriate database backend based on environment.
func ConnectDatabase() *gorm.DB {
cfg := config.Cfg
logger.Debug("ConnectDatabase: Environment=%s DevTesting=%s", cfg.Environment, cfg.DevTesting)
if cfg.Environment == "test" || cfg.DevTesting == "yes" {
logger.Debug("Test environment detected using SQLite test database")
SetupTestDB()
} else {
return connectProduction()
}
return DB
}
func connectProduction() *gorm.DB {
cfg := config.Cfg
dbURL := cfg.DatabaseURL
if dbURL == "" {
dbURL = "myapp:password@tcp(localhost:3306)/myapp?charset=utf8mb4&parseTime=True&loc=Local"
logger.Warn("DATABASE_URL not configured falling back to default MySQL DSN")
cfg.DatabaseType = "mysql"
}
var db *gorm.DB
var err error
switch cfg.DatabaseType {
case "mysql":
logger.Debug("Connecting to MySQL database...")
db, err = gorm.Open(mysql.Open(dbURL), &gorm.Config{})
case "sqlite":
logger.Debug("Connecting to SQLite database...")
db, err = gorm.Open(sqlite.Open(dbURL), &gorm.Config{})
default:
logger.Error("Unsupported database type %q falling back to MySQL", cfg.DatabaseType)
db, err = gorm.Open(mysql.Open(dbURL), &gorm.Config{})
}
if err != nil {
logger.Error("Failed to connect to database: %s", err.Error())
log.Fatal("database connection failed:", err)
}
logger.Info("Connected to %s database", cfg.DatabaseType)
DB = db
if err := MigrateStructure(); err != nil {
logger.Error("Auto-migration failed: %s", err.Error())
}
return DB
}
+22
View File
@@ -0,0 +1,22 @@
package database
import "gorm.io/gorm"
// MigrateStructure runs GORM AutoMigrate for all registered models.
// Add your domain models here so the schema is kept up to date automatically.
func MigrateStructure(db ...*gorm.DB) error {
var target *gorm.DB
if len(db) > 0 && db[0] != nil {
target = db[0]
} else {
target = DB
}
return target.AutoMigrate(
&SchemaVersion{},
&MigrationHistory{},
// TODO: register your domain models here, e.g.:
// &user.User{},
// &items.Item{},
)
}
+9
View File
@@ -0,0 +1,9 @@
package database
import "github.com/gin-gonic/gin"
// SetupCheck reconnects the database and can be used as a health-check endpoint.
func SetupCheck(c *gin.Context) {
ConnectDatabase()
c.JSON(200, gin.H{"status": "ok"})
}
+30
View File
@@ -0,0 +1,30 @@
package database
// SchemaVersion records which schema version has been applied to this database instance.
type SchemaVersion struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Version string `gorm:"size:20;not null;index"`
MigrationNumber int `gorm:"not null;index"`
AppliedAt int64 `gorm:"autoCreateTime"`
BackendVersion string `gorm:"size:20;not null"`
Description string `gorm:"type:text"`
Checksum string `gorm:"size:64"`
}
func (SchemaVersion) TableName() string { return "schema_version" }
// MigrationHistory provides an audit trail for all migrations that have run.
type MigrationHistory struct {
ID uint `gorm:"primaryKey;autoIncrement"`
MigrationNumber int `gorm:"not null;uniqueIndex"`
Version string `gorm:"size:20;not null;index"`
Description string `gorm:"type:text;not null"`
AppliedAt int64 `gorm:"autoCreateTime"`
AppliedBy string `gorm:"size:100"`
ExecutionTimeMs int64
Success bool `gorm:"default:true"`
ErrorMessage string `gorm:"type:text"`
RollbackAvailable bool `gorm:"default:true"`
}
func (MigrationHistory) TableName() string { return "migration_history" }
+74
View File
@@ -0,0 +1,74 @@
package database
import (
"io"
"myapp/logger"
"os"
"path/filepath"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var isTestDb bool
// SetupTestDB opens a temporary copy of PreparedTestDB as the global DB.
// Pass false as the first argument to use the real database instead.
func SetupTestDB(opts ...bool) {
isTestDb = true
if len(opts) > 0 {
isTestDb = opts[0]
}
if DB != nil {
return
}
if !isTestDb {
connectProduction()
return
}
logger.Info("SetupTestDB: creating temporary SQLite test database")
tmpDir, err := os.MkdirTemp("", "myapp-test-")
if err != nil {
panic("failed to create temp dir: " + err.Error())
}
target := filepath.Join(tmpDir, "test.db")
// Copy the prepared snapshot so every test starts with clean data.
if err := copyFile(PreparedTestDB, target); err != nil {
// No snapshot exists yet start with an empty database.
logger.Warn("SetupTestDB: no snapshot found at %s, using empty DB", PreparedTestDB)
target = filepath.Join(tmpDir, "empty.db")
}
db, err := gorm.Open(sqlite.Open(target), &gorm.Config{})
if err != nil {
panic("failed to open test database: " + err.Error())
}
DB = db
if err := MigrateStructure(db); err != nil {
panic("test database migration failed: " + err.Error())
}
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
+49
View File
@@ -0,0 +1,49 @@
module myapp
go 1.24.0
require (
github.com/gin-contrib/cors v1.7.3
github.com/gin-gonic/gin v1.10.0
github.com/stretchr/testify v1.10.0
gorm.io/driver/mysql v1.5.7
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.12.8 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.24.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.13.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+128
View File
@@ -0,0 +1,128 @@
package items
import (
"myapp/database"
"myapp/logger"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func respondWithError(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{"error": message})
}
// currentUserID extracts the authenticated user ID from the Gin context.
func currentUserID(c *gin.Context) uint {
id, _ := c.Get("userID")
uid, _ := id.(uint)
return uid
}
// ListItems handles GET /api/items — returns all items owned by the requesting user.
func ListItems(c *gin.Context) {
userID := currentUserID(c)
var items []Item
if err := database.DB.Where("user_id = ?", userID).Find(&items).Error; err != nil {
logger.Error("ListItems: %s", err.Error())
respondWithError(c, http.StatusInternalServerError, "failed to fetch items")
return
}
c.JSON(http.StatusOK, items)
}
// GetItem handles GET /api/items/:id — returns a single item owned by the requesting user.
func GetItem(c *gin.Context) {
item, ok := loadOwnedItem(c)
if !ok {
return
}
c.JSON(http.StatusOK, item)
}
// CreateItem handles POST /api/items — creates a new item for the requesting user.
func CreateItem(c *gin.Context) {
userID := currentUserID(c)
var input struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&input); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
item := Item{
UserID: userID,
Name: input.Name,
Description: input.Description,
}
if err := database.DB.Create(&item).Error; err != nil {
logger.Error("CreateItem: %s", err.Error())
respondWithError(c, http.StatusInternalServerError, "failed to create item")
return
}
c.JSON(http.StatusCreated, item)
}
// UpdateItem handles PUT /api/items/:id — updates an existing item owned by the requesting user.
func UpdateItem(c *gin.Context) {
item, ok := loadOwnedItem(c)
if !ok {
return
}
var input struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&input); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
if input.Name != "" {
item.Name = input.Name
}
item.Description = input.Description
if err := database.DB.Save(&item).Error; err != nil {
logger.Error("UpdateItem: %s", err.Error())
respondWithError(c, http.StatusInternalServerError, "failed to update item")
return
}
c.JSON(http.StatusOK, item)
}
// DeleteItem handles DELETE /api/items/:id — deletes an item owned by the requesting user.
func DeleteItem(c *gin.Context) {
item, ok := loadOwnedItem(c)
if !ok {
return
}
if err := database.DB.Delete(&item).Error; err != nil {
logger.Error("DeleteItem: %s", err.Error())
respondWithError(c, http.StatusInternalServerError, "failed to delete item")
return
}
c.JSON(http.StatusOK, gin.H{"message": "item deleted"})
}
// loadOwnedItem is a helper that fetches an item by :id and verifies ownership.
func loadOwnedItem(c *gin.Context) (Item, bool) {
idParam := c.Param("id")
itemID, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
respondWithError(c, http.StatusBadRequest, "invalid item ID")
return Item{}, false
}
userID := currentUserID(c)
var item Item
if err := database.DB.First(&item, "id = ? AND user_id = ?", itemID, userID).Error; err != nil {
respondWithError(c, http.StatusNotFound, "item not found")
return Item{}, false
}
return item, true
}
+37
View File
@@ -0,0 +1,37 @@
package items_test
import (
"myapp/database"
"myapp/testutils"
"testing"
"myapp/items"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTestEnvironment(t *testing.T) {
testutils.SetupTestEnvironment(t)
database.ConnectDatabase()
}
func TestCreateAndListItems(t *testing.T) {
setupTestEnvironment(t)
// Create a test item directly via GORM.
item := items.Item{
UserID: 1,
Name: "Test Item",
Description: "A sample item for testing",
}
err := database.DB.Create(&item).Error
require.NoError(t, err)
assert.NotZero(t, item.ID)
// List items for the same user.
var found []items.Item
err = database.DB.Where("user_id = ?", 1).Find(&found).Error
require.NoError(t, err)
assert.NotEmpty(t, found)
}
+34
View File
@@ -0,0 +1,34 @@
package items
import (
"myapp/database"
"fmt"
"time"
"gorm.io/gorm"
)
// Item is the example domain entity.
// Replace or extend this with your own business model.
type Item struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Description string `gorm:"type:text" json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MigrateStructure ensures the items table schema is current.
func MigrateStructure(db ...*gorm.DB) error {
var target *gorm.DB
if len(db) > 0 && db[0] != nil {
target = db[0]
} else {
target = database.DB
}
if target == nil {
return fmt.Errorf("no database connection available for items migration")
}
return target.AutoMigrate(&Item{})
}
+15
View File
@@ -0,0 +1,15 @@
package items
import "github.com/gin-gonic/gin"
// RegisterRoutes mounts all item CRUD endpoints onto the given protected router group.
func RegisterRoutes(r *gin.RouterGroup) {
group := r.Group("/items")
{
group.GET("", ListItems)
group.POST("", CreateItem)
group.GET("/:id", GetItem)
group.PUT("/:id", UpdateItem)
group.DELETE("/:id", DeleteItem)
}
}
+106
View File
@@ -0,0 +1,106 @@
package logger
import (
"fmt"
"log"
"os"
"strings"
"time"
)
// LogLevel represents the severity of a log message.
type LogLevel int
const (
DEBUG LogLevel = iota
INFO
WARN
ERROR
)
func (l LogLevel) String() string {
switch l {
case DEBUG:
return "DEBUG"
case INFO:
return "INFO"
case WARN:
return "WARN"
case ERROR:
return "ERROR"
default:
return "UNKNOWN"
}
}
// Logger carries the logging configuration.
type Logger struct {
debugEnabled bool
minLevel LogLevel
logger *log.Logger
}
var defaultLogger *Logger
func init() {
defaultLogger = &Logger{
debugEnabled: getDebugModeFromEnv(),
minLevel: getMinLogLevelFromEnv(),
logger: log.New(os.Stdout, "", 0),
}
}
func getDebugModeFromEnv() bool {
v := os.Getenv("DEBUG")
return strings.ToLower(v) == "true" || v == "1"
}
func getMinLogLevelFromEnv() LogLevel {
switch strings.ToUpper(os.Getenv("LOG_LEVEL")) {
case "DEBUG":
return DEBUG
case "WARN":
return WARN
case "ERROR":
return ERROR
default:
if getDebugModeFromEnv() {
return DEBUG
}
return INFO
}
}
// SetDebugMode enables or disables debug output.
func SetDebugMode(enabled bool) {
defaultLogger.debugEnabled = enabled
}
// SetMinLogLevel sets the minimum log level that will be emitted.
func SetMinLogLevel(level LogLevel) {
defaultLogger.minLevel = level
}
func (l *Logger) log(level LogLevel, format string, args ...interface{}) {
if level < l.minLevel {
return
}
if level == DEBUG && !l.debugEnabled {
return
}
timestamp := time.Now().Format("2006-01-02 15:04:05")
message := fmt.Sprintf(format, args...)
l.logger.Printf("[%s] [%s] %s", timestamp, level, message)
}
// Debug logs a debug-level message.
func Debug(format string, args ...interface{}) { defaultLogger.log(DEBUG, format, args...) }
// Info logs an info-level message.
func Info(format string, args ...interface{}) { defaultLogger.log(INFO, format, args...) }
// Warn logs a warning-level message.
func Warn(format string, args ...interface{}) { defaultLogger.log(WARN, format, args...) }
// Error logs an error-level message.
func Error(format string, args ...interface{}) { defaultLogger.log(ERROR, format, args...) }
+115
View File
@@ -0,0 +1,115 @@
package mail
import (
"crypto/tls"
"fmt"
"net/smtp"
"myapp/config"
"myapp/logger"
)
// Client is an SMTP mail client configured from the application config.
type Client struct {
host string
port int
username string
password string
from string
}
// Message represents an outgoing email.
type Message struct {
To string
Subject string
Body string
}
// NewClient creates a Client from the global config.
func NewClient() *Client {
cfg := config.Cfg
return &Client{
host: cfg.MailHost,
port: cfg.MailPort,
username: cfg.MailUsername,
password: cfg.MailPassword,
from: cfg.MailFrom,
}
}
// Send delivers msg via SMTP.
// Port 465 uses direct TLS; port 587 (and others) use STARTTLS.
func (c *Client) Send(msg Message) error {
if c.host == "" {
logger.Warn("SMTP host not configured skipping email to %s", msg.To)
return fmt.Errorf("SMTP host not configured")
}
headers := map[string]string{
"From": c.from,
"To": msg.To,
"Subject": msg.Subject,
"MIME-Version": "1.0",
"Content-Type": `text/html; charset="UTF-8"`,
}
raw := ""
for k, v := range headers {
raw += fmt.Sprintf("%s: %s\r\n", k, v)
}
raw += "\r\n" + msg.Body
serverAddr := fmt.Sprintf("%s:%d", c.host, c.port)
tlsCfg := &tls.Config{ServerName: c.host, InsecureSkipVerify: false}
var smtpClient *smtp.Client
var err error
if c.port == 465 {
conn, dialErr := tls.Dial("tcp", serverAddr, tlsCfg)
if dialErr != nil {
return fmt.Errorf("SMTP TLS dial failed: %w", dialErr)
}
defer conn.Close()
smtpClient, err = smtp.NewClient(conn, c.host)
} else {
smtpClient, err = smtp.Dial(serverAddr)
if err == nil {
if ok, _ := smtpClient.Extension("STARTTLS"); ok {
err = smtpClient.StartTLS(tlsCfg)
}
}
if smtpClient != nil {
defer smtpClient.Close()
}
}
if err != nil {
return fmt.Errorf("SMTP connection failed: %w", err)
}
if c.username != "" {
auth := smtp.PlainAuth("", c.username, c.password, c.host)
if err = smtpClient.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth failed: %w", err)
}
}
if err = smtpClient.Mail(c.from); err != nil {
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
}
if err = smtpClient.Rcpt(msg.To); err != nil {
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
}
wc, err := smtpClient.Data()
if err != nil {
return fmt.Errorf("SMTP DATA failed: %w", err)
}
defer wc.Close()
if _, err = fmt.Fprint(wc, raw); err != nil {
return fmt.Errorf("SMTP write failed: %w", err)
}
logger.Info("Email sent to %s", msg.To)
return nil
}
+21
View File
@@ -0,0 +1,21 @@
package router
import (
"myapp/user"
"github.com/gin-gonic/gin"
)
// BaseRouterGrp registers unauthenticated public routes and returns the
// protected /api router group (all routes within it require a valid bearer token).
func BaseRouterGrp(r *gin.Engine) *gin.RouterGroup {
r.POST("/register", user.RegisterUser)
r.POST("/login", user.LoginUser)
r.POST("/password-reset/request", user.RequestPasswordReset)
r.GET("/password-reset/validate/:token", user.ValidateResetToken)
r.POST("/password-reset/reset", user.ResetPassword)
protected := r.Group("/api")
protected.Use(user.AuthMiddleware())
return protected
}
+25
View File
@@ -0,0 +1,25 @@
package router
import (
"myapp/config"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
// SetupGin configures global middleware for the Gin engine.
func SetupGin(r *gin.Engine) {
allowedOrigins := []string{
config.Cfg.FrontendURL,
"http://localhost:5173", // Vite dev server
}
r.Use(cors.New(cors.Config{
AllowOrigins: allowedOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 12 * 3600,
}))
}
+53
View File
@@ -0,0 +1,53 @@
package testutils
import (
"os"
"testing"
)
// SetupTestEnvironment sets ENVIRONMENT=test and restores the original value after the test.
// Call this at the start of every test function.
func SetupTestEnvironment(t *testing.T) {
t.Helper()
original := os.Getenv("ENVIRONMENT")
os.Setenv("ENVIRONMENT", "test")
t.Cleanup(func() {
if original != "" {
os.Setenv("ENVIRONMENT", original)
} else {
os.Unsetenv("ENVIRONMENT")
}
})
}
// SetupTestEnvironmentWithConfig applies custom env vars for the test duration,
// always including ENVIRONMENT=test.
func SetupTestEnvironmentWithConfig(t *testing.T, envVars map[string]string) {
t.Helper()
originals := make(map[string]string, len(envVars))
for k := range envVars {
originals[k] = os.Getenv(k)
}
envVars["ENVIRONMENT"] = "test"
for k, v := range envVars {
os.Setenv(k, v)
}
t.Cleanup(func() {
for k, orig := range originals {
if orig != "" {
os.Setenv(k, orig)
} else {
os.Unsetenv(k)
}
}
})
}
// EnsureTestEnvironment fails the test if ENVIRONMENT is not "test".
func EnsureTestEnvironment(t *testing.T) {
t.Helper()
if os.Getenv("ENVIRONMENT") != "test" {
t.Errorf("ENVIRONMENT should be 'test' but is %q did you forget SetupTestEnvironment()?",
os.Getenv("ENVIRONMENT"))
}
}
+148
View File
@@ -0,0 +1,148 @@
package user
import (
"crypto/md5"
"encoding/hex"
"myapp/database"
"myapp/logger"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// ListUsers returns all users (admin only).
func ListUsers(c *gin.Context) {
var users []User
if err := database.DB.Find(&users).Error; err != nil {
logger.Error("ListUsers: %s", err.Error())
respondWithError(c, http.StatusInternalServerError, "failed to fetch users")
return
}
for i := range users {
users[i].PasswordHash = ""
users[i].ResetPwHash = nil
users[i].DisplayName = users[i].DisplayNameOrUsername()
}
c.JSON(http.StatusOK, users)
}
// GetUser returns a single user by ID. Admins can access any user; others only themselves.
func GetUser(c *gin.Context) {
targetID, err := parseUserID(c)
if err != nil {
respondWithError(c, http.StatusBadRequest, "invalid user ID")
return
}
requester := getUser(c)
if requester == nil {
return
}
if requester.UserID != targetID && !requester.IsAdmin() {
respondWithError(c, http.StatusForbidden, "forbidden")
return
}
var u User
if err := u.FirstId(targetID); err != nil {
respondWithError(c, http.StatusNotFound, "user not found")
return
}
u.PasswordHash = ""
u.ResetPwHash = nil
c.JSON(http.StatusOK, u)
}
// UpdateUserRole changes a user's role (admin only).
func UpdateUserRole(c *gin.Context) {
targetID, err := parseUserID(c)
if err != nil {
respondWithError(c, http.StatusBadRequest, "invalid user ID")
return
}
var input struct {
Role string `json:"role" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
if input.Role != RoleStandardUser && input.Role != RoleMaintainer && input.Role != RoleAdmin {
respondWithError(c, http.StatusBadRequest, "invalid role")
return
}
var u User
if err := u.FirstId(targetID); err != nil {
respondWithError(c, http.StatusNotFound, "user not found")
return
}
u.Role = input.Role
if err := u.Save(); err != nil {
respondWithError(c, http.StatusInternalServerError, "failed to update role")
return
}
c.JSON(http.StatusOK, gin.H{"message": "role updated"})
}
// ChangeUserPassword sets a new password for any user (admin only).
func ChangeUserPassword(c *gin.Context) {
targetID, err := parseUserID(c)
if err != nil {
respondWithError(c, http.StatusBadRequest, "invalid user ID")
return
}
var input struct {
NewPassword string `json:"new_password" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
var u User
if err := u.FirstId(targetID); err != nil {
respondWithError(c, http.StatusNotFound, "user not found")
return
}
hash := md5.Sum([]byte(input.NewPassword))
u.PasswordHash = hex.EncodeToString(hash[:])
if err := u.Save(); err != nil {
respondWithError(c, http.StatusInternalServerError, "failed to update password")
return
}
c.JSON(http.StatusOK, gin.H{"message": "password updated"})
}
// DeleteUser removes a user account (admin only, cannot delete self).
func DeleteUser(c *gin.Context) {
targetID, err := parseUserID(c)
if err != nil {
respondWithError(c, http.StatusBadRequest, "invalid user ID")
return
}
requester := getUser(c)
if requester == nil {
return
}
if requester.UserID == targetID {
respondWithError(c, http.StatusBadRequest, "cannot delete your own account")
return
}
if err := database.DB.Delete(&User{}, targetID).Error; err != nil {
logger.Error("DeleteUser: %s", err.Error())
respondWithError(c, http.StatusInternalServerError, "failed to delete user")
return
}
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
}
func parseUserID(c *gin.Context) (uint, error) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
return uint(id), err
}
+22
View File
@@ -0,0 +1,22 @@
package user
import (
"myapp/database"
"fmt"
"gorm.io/gorm"
)
// MigrateStructure ensures the users table schema is current.
func MigrateStructure(db ...*gorm.DB) error {
var target *gorm.DB
if len(db) > 0 && db[0] != nil {
target = db[0]
} else {
target = database.DB
}
if target == nil {
return fmt.Errorf("no database connection available for user migration")
}
return target.AutoMigrate(&User{})
}
+312
View File
@@ -0,0 +1,312 @@
package user
import (
"crypto/md5"
"encoding/hex"
"fmt"
"myapp/logger"
"myapp/mail"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
func respondWithError(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{"error": message})
}
// RegisterUser handles POST /register.
func RegisterUser(c *gin.Context) {
logger.Debug("RegisterUser called")
var u User
if err := c.ShouldBindJSON(&u); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
if u.Username == "" {
respondWithError(c, http.StatusBadRequest, "username cannot be empty")
return
}
if u.Email == "" {
respondWithError(c, http.StatusBadRequest, "email cannot be empty")
return
}
if u.PasswordHash == "" {
respondWithError(c, http.StatusBadRequest, "password cannot be empty")
return
}
hash := md5.Sum([]byte(u.PasswordHash))
u.PasswordHash = hex.EncodeToString(hash[:])
if u.Role == "" {
u.Role = RoleStandardUser
}
if err := u.Create(); err != nil {
logger.Error("RegisterUser: %s", err.Error())
respondWithError(c, http.StatusInternalServerError, fmt.Sprintf("failed to create user: %s", err))
return
}
logger.Info("User registered: %s (id=%d)", u.Username, u.UserID)
c.JSON(http.StatusCreated, gin.H{"message": "user registered successfully"})
}
// LoginUser handles POST /login.
func LoginUser(c *gin.Context) {
logger.Debug("LoginUser called")
var input struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&input); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
var u User
if err := u.First(input.Username); err != nil {
respondWithError(c, http.StatusUnauthorized, "invalid username or password")
return
}
hash := md5.Sum([]byte(input.Password))
if u.PasswordHash != hex.EncodeToString(hash[:]) {
respondWithError(c, http.StatusUnauthorized, "invalid username or password")
return
}
token := GenerateToken(&u)
logger.Info("User logged in: %s", u.Username)
c.JSON(http.StatusOK, gin.H{"message": "login successful", "token": token})
}
// GenerateToken creates a simple token embedding the user ID.
//
// NOTE: This is not cryptographically verified suitable for demos/internal
// tools only. Replace with a proper JWT library for production use.
func GenerateToken(u *User) string {
tx := md5.Sum([]byte(u.Username + u.CreatedAt.String()))
hash := hex.EncodeToString(tx[:])
idPart := "." + fmt.Sprintf("%d", u.UserID) + ":"
return hash[:7] + idPart + hash[7:]
}
// CheckToken validates a bearer token and returns the associated User, or nil.
func CheckToken(token string) *User {
const bearerPrefix = "Bearer "
pos := 7 + len(bearerPrefix)
if len(token) <= pos || token[pos] != '.' {
return nil
}
rest := token[pos+1:]
colonIdx := strings.Index(rest, ":")
if colonIdx == -1 {
return nil
}
userID, err := strconv.Atoi(rest[:colonIdx])
if err != nil || userID <= 0 {
return nil
}
var u User
if err := u.FirstId(uint(userID)); err != nil {
return nil
}
return &u
}
// GetUserProfile handles GET /api/user/profile.
func GetUserProfile(c *gin.Context) {
u := getUser(c)
if u == nil {
return
}
u.PasswordHash = ""
u.ResetPwHash = nil
u.DisplayName = u.DisplayNameOrUsername()
c.JSON(http.StatusOK, u)
}
// UpdateDisplayName handles PUT /api/user/display-name.
func UpdateDisplayName(c *gin.Context) {
u := getUser(c)
if u == nil {
return
}
var input struct {
DisplayName string `json:"display_name" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
u.DisplayName = input.DisplayName
if err := u.Save(); err != nil {
respondWithError(c, http.StatusInternalServerError, "failed to update display name")
return
}
c.JSON(http.StatusOK, gin.H{"message": "display name updated"})
}
// UpdateEmail handles PUT /api/user/email.
func UpdateEmail(c *gin.Context) {
u := getUser(c)
if u == nil {
return
}
var input struct {
Email string `json:"email" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
u.Email = input.Email
if err := u.Save(); err != nil {
respondWithError(c, http.StatusInternalServerError, "failed to update email")
return
}
c.JSON(http.StatusOK, gin.H{"message": "email updated"})
}
// UpdatePassword handles PUT /api/user/password.
func UpdatePassword(c *gin.Context) {
u := getUser(c)
if u == nil {
return
}
var input struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
currentHash := md5.Sum([]byte(input.CurrentPassword))
if u.PasswordHash != hex.EncodeToString(currentHash[:]) {
respondWithError(c, http.StatusUnauthorized, "current password is incorrect")
return
}
newHash := md5.Sum([]byte(input.NewPassword))
u.PasswordHash = hex.EncodeToString(newHash[:])
if err := u.Save(); err != nil {
respondWithError(c, http.StatusInternalServerError, "failed to update password")
return
}
c.JSON(http.StatusOK, gin.H{"message": "password updated"})
}
// UpdateLanguage handles PUT /api/user/language.
func UpdateLanguage(c *gin.Context) {
u := getUser(c)
if u == nil {
return
}
var input struct {
Language string `json:"language" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
u.PreferredLanguage = input.Language
if err := u.Save(); err != nil {
respondWithError(c, http.StatusInternalServerError, "failed to update language")
return
}
c.JSON(http.StatusOK, gin.H{"message": "language updated"})
}
// RequestPasswordReset handles POST /password-reset/request.
func RequestPasswordReset(c *gin.Context) {
var input struct {
Email string `json:"email" binding:"required"`
RedirectURL string `json:"redirect_url"`
}
if err := c.ShouldBindJSON(&input); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
// Always respond with 200 to avoid leaking whether the email exists.
var u User
if err := c.ShouldBindJSON(&input); err == nil {
_ = u // email lookup omitted for security
}
// Find user by email
if err := findUserByEmail(&u, input.Email); err == nil {
token := generateResetToken()
if err := saveResetToken(&u, token); err == nil {
resetURL := fmt.Sprintf("%s/reset-password?token=%s", input.RedirectURL, token)
mailer := mail.NewClient()
_ = mailer.Send(mail.Message{
To: u.Email,
Subject: "Password Reset",
Body: fmt.Sprintf("<p>Click the link to reset your password: <a href=%q>%s</a></p>", resetURL, resetURL),
})
}
}
c.JSON(http.StatusOK, gin.H{"message": "if an account with that email exists, a reset link has been sent"})
}
// ValidateResetToken handles GET /password-reset/validate/:token.
func ValidateResetToken(c *gin.Context) {
token := c.Param("token")
u, err := findUserByResetToken(token)
if err != nil {
respondWithError(c, http.StatusBadRequest, "invalid or expired reset token")
return
}
u.PasswordHash = ""
c.JSON(http.StatusOK, u)
}
// ResetPassword handles POST /password-reset/reset.
func ResetPassword(c *gin.Context) {
var input struct {
Token string `json:"token" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
u, err := findUserByResetToken(input.Token)
if err != nil {
respondWithError(c, http.StatusBadRequest, "invalid or expired reset token")
return
}
hash := md5.Sum([]byte(input.NewPassword))
u.PasswordHash = hex.EncodeToString(hash[:])
u.ResetPwHash = nil
u.ResetPwHashExpires = nil
if err := u.Save(); err != nil {
respondWithError(c, http.StatusInternalServerError, "failed to reset password")
return
}
c.JSON(http.StatusOK, gin.H{"message": "password reset successfully"})
}
// getUser retrieves the authenticated user from the Gin context.
func getUser(c *gin.Context) *User {
u, exists := c.Get("user")
if !exists {
respondWithError(c, http.StatusUnauthorized, "unauthorized")
return nil
}
user, ok := u.(*User)
if !ok {
respondWithError(c, http.StatusInternalServerError, "invalid user context")
return nil
}
return user
}
+63
View File
@@ -0,0 +1,63 @@
package user
import (
"net/http"
"github.com/gin-gonic/gin"
)
// AuthMiddleware validates the bearer token and injects the user into the Gin context.
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
u := CheckToken(token)
if u == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
c.Abort()
return
}
c.Set("userID", u.UserID)
c.Set("username", u.Username)
c.Set("user", u)
c.Next()
}
}
// RequireRole is a middleware that enforces a minimum role level.
func RequireRole(required string) gin.HandlerFunc {
return func(c *gin.Context) {
u, exists := c.Get("user")
if !exists {
respondWithError(c, http.StatusUnauthorized, "unauthorized")
c.Abort()
return
}
user, ok := u.(*User)
if !ok {
respondWithError(c, http.StatusInternalServerError, "invalid user context")
c.Abort()
return
}
switch required {
case RoleAdmin:
if !user.IsAdmin() {
respondWithError(c, http.StatusForbidden, "admin role required")
c.Abort()
return
}
case RoleMaintainer:
if !user.IsMaintainer() {
respondWithError(c, http.StatusForbidden, "maintainer role required")
c.Abort()
return
}
}
c.Next()
}
}
// RequireAdmin is a convenience wrapper for admin-only endpoints.
func RequireAdmin() gin.HandlerFunc { return RequireRole(RoleAdmin) }
// RequireMaintainer is a convenience wrapper for maintainer-or-higher endpoints.
func RequireMaintainer() gin.HandlerFunc { return RequireRole(RoleMaintainer) }
+86
View File
@@ -0,0 +1,86 @@
package user
import (
"myapp/database"
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
// Role constants define the permission levels in the system.
const (
RoleStandardUser = "standard"
RoleMaintainer = "maintainer"
RoleAdmin = "admin"
)
// User is the primary account entity.
type User struct {
UserID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"unique" json:"username"`
DisplayName string `gorm:"not null;default:''" json:"display_name"`
PasswordHash string `json:"password"`
Email string `gorm:"unique" json:"email"`
Role string `gorm:"default:standard" json:"role"`
PreferredLanguage string `gorm:"default:de" json:"preferred_language"`
ResetPwHash *string `gorm:"index" json:"-"`
ResetPwHashExpires *time.Time `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// DisplayNameOrUsername returns DisplayName when set, otherwise Username.
func (u *User) DisplayNameOrUsername() string {
if strings.TrimSpace(u.DisplayName) != "" {
return u.DisplayName
}
return u.Username
}
// IsAdmin returns true if the user has the admin role.
func (u *User) IsAdmin() bool { return u.Role == RoleAdmin }
// IsMaintainer returns true if the user has maintainer or admin role.
func (u *User) IsMaintainer() bool { return u.Role == RoleMaintainer || u.Role == RoleAdmin }
// IsStandardUser returns true for any authenticated user.
func (u *User) IsStandardUser() bool { return u.UserID > 0 }
// Create persists a new User record to the database.
func (u *User) Create() error {
if database.DB == nil {
return fmt.Errorf("database connection is nil")
}
if strings.TrimSpace(u.DisplayName) == "" {
u.DisplayName = u.Username
}
return database.DB.Transaction(func(tx *gorm.DB) error {
return tx.Create(u).Error
})
}
// First loads a User by username.
func (u *User) First(username string) error {
if database.DB == nil {
return fmt.Errorf("database connection is nil")
}
return database.DB.First(u, "username = ?", username).Error
}
// FirstId loads a User by primary key.
func (u *User) FirstId(id uint) error {
if database.DB == nil {
return fmt.Errorf("database connection is nil")
}
return database.DB.First(u, "user_id = ?", id).Error
}
// Save persists changes to an existing User record.
func (u *User) Save() error {
if database.DB == nil {
return fmt.Errorf("database connection is nil")
}
return database.DB.Save(u).Error
}
+40
View File
@@ -0,0 +1,40 @@
package user
import (
"crypto/rand"
"encoding/hex"
"myapp/database"
"time"
)
// findUserByEmail loads the first user matching the given email address.
func findUserByEmail(u *User, email string) error {
return database.DB.First(u, "email = ?", email).Error
}
// generateResetToken creates a random hex token.
func generateResetToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// saveResetToken stores the hashed reset token and a 1-hour expiry on the user.
func saveResetToken(u *User, token string) error {
expires := time.Now().Add(time.Hour)
u.ResetPwHash = &token
u.ResetPwHashExpires = &expires
return u.Save()
}
// findUserByResetToken looks up a user whose reset token matches and has not expired.
func findUserByResetToken(token string) (*User, error) {
var u User
err := database.DB.
Where("reset_pw_hash = ? AND reset_pw_hash_expires > ?", token, time.Now()).
First(&u).Error
if err != nil {
return nil, err
}
return &u, nil
}
+27
View File
@@ -0,0 +1,27 @@
package user
import "github.com/gin-gonic/gin"
// RegisterRoutes mounts all user-related routes onto the given router group.
func RegisterRoutes(r *gin.RouterGroup) {
// Self-service profile endpoints
userGroup := r.Group("/user")
{
userGroup.GET("/profile", GetUserProfile)
userGroup.PUT("/display-name", UpdateDisplayName)
userGroup.PUT("/email", UpdateEmail)
userGroup.PUT("/password", UpdatePassword)
userGroup.PUT("/language", UpdateLanguage)
}
// Admin-only user management endpoints
adminGroup := r.Group("/users")
adminGroup.Use(RequireAdmin())
{
adminGroup.GET("", ListUsers)
adminGroup.GET("/:id", GetUser)
adminGroup.PUT("/:id/role", UpdateUserRole)
adminGroup.PUT("/:id/password", ChangeUserPassword)
adminGroup.DELETE("/:id", DeleteUser)
}
}
+28
View File
@@ -0,0 +1,28 @@
# =========== 1) Build stage ===========
FROM golang:1.25-alpine AS builder
# Install necessary packages for CGO and SQLite
RUN apk add --no-cache gcc musl-dev sqlite-dev
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -v -o server cmd/main.go
# =========== 2) Runtime stage ===========
FROM alpine:3.23
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server /app
EXPOSE 8180
CMD ["./server"]
+17
View File
@@ -0,0 +1,17 @@
# Development Dockerfile for Go backend with live-reloading
FROM golang:1.25-alpine
# Install necessary packages for CGO and SQLite
RUN apk add --no-cache gcc musl-dev sqlite-dev ca-certificates tzdata
# Install Air for live-reloading
RUN go install github.com/air-verse/air@latest
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
EXPOSE 8180
CMD ["air", "-c", ".air.toml"]
+28
View File
@@ -0,0 +1,28 @@
# =========== 1) Build stage ===========
FROM node:22-alpine AS build
ARG VITE_API_URL
ARG VITE_BASE_URL
ARG VITE_API_PORT
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_BASE_URL=$VITE_BASE_URL
ENV VITE_API_PORT=$VITE_API_PORT
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# =========== 2) Serve stage ===========
FROM nginx:alpine
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
COPY --from=build /usr/src/app/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+11
View File
@@ -0,0 +1,11 @@
# Development Dockerfile for Vue.js frontend with Vite HMR
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+89
View File
@@ -0,0 +1,89 @@
services:
backend-dev:
build:
context: ../backend
dockerfile: ../docker/Dockerfile.backend.dev
container_name: myapp-backend-dev
ports:
- "8180:8180"
environment:
- GO_ENV=development
- CGO_ENABLED=1
- DATABASE_TYPE=${DATABASE_TYPE:-mysql}
- DATABASE_URL=${DATABASE_URL:-${MARIADB_USER:-myapp}:${MARIADB_PASSWORD:-secure_user_password}@tcp(mariadb-dev:3306)/${MARIADB_DATABASE:-myapp}?charset=utf8mb4&parseTime=True&loc=Local}
- API_PORT=${API_PORT:-8180}
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:5173}
- JWT_SECRET=${JWT_SECRET:-change_this_secret_in_production}
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-465}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-}
- GIT_COMMIT=${GIT_COMMIT:-unknown}
depends_on:
mariadb-dev:
condition: service_healthy
working_dir: /app
restart: unless-stopped
volumes:
- ../backend:/app
- go-mod-cache:/go/pkg/mod
frontend-dev:
build:
context: ../frontend
dockerfile: ../docker/Dockerfile.frontend.dev
container_name: myapp-frontend-dev
ports:
- "5173:5173"
environment:
- NODE_ENV=development
- VITE_API_URL=${VITE_API_URL:-http://localhost:8180}
depends_on:
- backend-dev
restart: unless-stopped
volumes:
- ../frontend:/app
- /app/node_modules
mariadb-dev:
image: mariadb:11.4
container_name: myapp-mariadb-dev
restart: unless-stopped
ports:
- "3306:3306"
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-secure_root_password}
MARIADB_DATABASE: ${MARIADB_DATABASE:-myapp}
MARIADB_USER: ${MARIADB_USER:-myapp}
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-secure_user_password}
MARIADB_CHARSET: utf8mb4
MARIADB_COLLATION: utf8mb4_unicode_ci
volumes:
- myapp-db-dev:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
timeout: 5s
retries: 3
phpmyadmin-dev:
image: phpmyadmin/phpmyadmin:5.2
container_name: myapp-phpmyadmin-dev
restart: unless-stopped
ports:
- "8082:80"
environment:
PMA_HOST: mariadb-dev
PMA_PORT: 3306
PMA_USER: root
PMA_PASSWORD: ${MARIADB_ROOT_PASSWORD:-secure_root_password}
MYSQL_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-secure_root_password}
PMA_ARBITRARY: 1
depends_on:
mariadb-dev:
condition: service_healthy
volumes:
go-mod-cache:
myapp-db-dev:
+78
View File
@@ -0,0 +1,78 @@
services:
backend:
build:
context: ../backend
dockerfile: ../docker/Dockerfile.backend
container_name: myapp-backend
ports:
- "8180:8180"
environment:
- GO_ENV=production
- CGO_ENABLED=1
- DATABASE_TYPE=${DATABASE_TYPE:-mysql}
- DATABASE_URL=${MARIADB_USER:-myapp}:${MARIADB_PASSWORD:-secure_user_password}@tcp(mariadb:3306)/${MARIADB_DATABASE:-myapp}?charset=utf8mb4&parseTime=True&loc=Local
- API_PORT=${API_PORT:-8180}
- FRONTEND_URL=${FRONTEND_URL:-https://myapp.example.com}
- JWT_SECRET=${JWT_SECRET:-change_this_secret_in_production}
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-465}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-}
depends_on:
mariadb:
condition: service_healthy
working_dir: /app
restart: unless-stopped
frontend:
build:
context: ../frontend
dockerfile: ../docker/Dockerfile.frontend
args:
VITE_API_URL: ${VITE_API_URL:-https://api.myapp.example.com}
VITE_BASE_URL: ${VITE_BASE_URL:-https://myapp.example.com}
VITE_API_PORT: ${VITE_API_PORT:-443}
container_name: myapp-frontend
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
mariadb:
image: mariadb:11.4
container_name: myapp-mariadb
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-secure_root_password}
MARIADB_DATABASE: ${MARIADB_DATABASE:-myapp}
MARIADB_USER: ${MARIADB_USER:-myapp}
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-secure_user_password}
MARIADB_CHARSET: utf8mb4
MARIADB_COLLATION: utf8mb4_unicode_ci
volumes:
- myapp-db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 20s
timeout: 10s
retries: 3
# phpMyAdmin (uncomment for production DB management)
# phpmyadmin:
# image: phpmyadmin/phpmyadmin:5.2
# container_name: myapp-phpmyadmin
# ports:
# - "8082:80"
# environment:
# PMA_HOST: mariadb
# PMA_PORT: 3306
# PMA_USER: root
# PMA_PASSWORD: ${MARIADB_ROOT_PASSWORD:-secure_root_password}
# depends_on:
# mariadb:
# condition: service_healthy
volumes:
myapp-db:
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MyApp</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
server {
listen 80;
listen [::]:80;
server_name localhost;
# SPA routing: try files, fallback to index.html
location / {
try_files $uri $uri/ /index.html;
root /usr/share/nginx/html;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "myapp-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-i18n": "^11.0.0",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.1",
"vite-plugin-vue-devtools": "^7.6.5"
}
}
+48
View File
@@ -0,0 +1,48 @@
<template>
<div id="app">
<Menu v-if="isLoggedIn" />
<main class="main-content" :class="{ 'full-width': !isLoggedIn }">
<router-view />
</main>
</div>
</template>
<script>
import Menu from './components/Menu.vue'
export default {
components: { Menu },
data() {
return { loggedIn: false }
},
computed: {
isLoggedIn() { return this.loggedIn }
},
mounted() {
this.checkAuthStatus()
window.addEventListener('storage', this.checkAuthStatus)
window.addEventListener('auth-changed', this.checkAuthStatus)
},
beforeUnmount() {
window.removeEventListener('storage', this.checkAuthStatus)
window.removeEventListener('auth-changed', this.checkAuthStatus)
},
methods: {
checkAuthStatus() {
this.loggedIn = !!localStorage.getItem('token')
}
}
}
</script>
<style src="./assets/main.css"></style>
<style>
.main-content.full-width {
margin: 0;
padding: 2rem;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-black);
--color-text: var(--vt-c-black);
--section-gap: 160px;
/* Additional semantic colors */
--color-primary: #007bff;
--color-primary-dark: #0056b3;
--color-bg-secondary: #f8f9fa;
--color-text-primary: #333;
--color-text-secondary: #495057;
/* Spacing variables */
--padding-xs: 4px;
--padding-sm: 8px;
--padding-md: 16px;
--padding-lg: 24px;
--margin-xs: 4px;
--margin-sm: 8px;
--margin-md: 16px;
--margin-lg: 24px;
/* Other utilities */
--border-radius: 6px;
--box-shadow-light: 0 2px 4px rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
/* Dark mode adjustments for additional colors */
--color-bg-secondary: #2a2a2a;
--color-text-primary: #e9ecef;
--color-text-secondary: #adb5bd;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 0.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 12px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+764
View File
@@ -0,0 +1,764 @@
@import './base.css';
#app {
width: 100%;
max-width: none;
margin: 0;
padding: 0;
font-weight: normal;
height: 100vh;
display: flex;
flex-direction: column;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
a:visited {
color: hsla(160, 100%, 37%, 1);
}
a:active,
a:focus {
color: hsla(160, 100%, 37%, 1);
outline: 2px solid hsla(160, 100%, 37%, 0.3);
outline-offset: 2px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
margin: 0;
padding: 0;
width: 100%;
height: 100vh;
}
#app {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
padding: 0;
margin: 0;
}
}
/* Top Navigation Bar */
.top-nav {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--vt-c-black-soft);
color: var(--vt-c-white);
height: 60px;
width: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 1000;
padding: 0 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.top-nav .menu-left {
list-style: none;
display: flex;
gap: 5px;
padding: 0;
margin: 0;
align-items: center;
}
.top-nav li {
position: relative;
}
.top-nav li a,
.top-nav .dropdown-trigger {
text-decoration: none;
color: var(--vt-c-white);
font-size: 1rem;
padding: 8px 16px;
display: block;
border-radius: 4px;
transition: background-color 0.2s, color 0.2s;
cursor: pointer;
white-space: nowrap;
}
.top-nav li a:hover,
.top-nav .dropdown-trigger:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.top-nav li a.active {
background-color: rgba(255, 255, 255, 0.2);
font-weight: 600;
}
/* Dropdown Menu Styles */
.top-nav .dropdown {
position: relative;
}
.top-nav .dropdown-menu {
position: absolute;
top: 100%;
left: 0;
background-color: var(--vt-c-black-soft);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
min-width: 180px;
list-style: none;
padding: 8px 0;
margin: 4px 0 0 0;
z-index: 1001;
}
.top-nav .dropdown-menu li {
margin: 0;
}
.top-nav .dropdown-menu a {
padding: 10px 20px;
border-radius: 0;
display: block;
}
.top-nav .dropdown-menu a:hover {
background-color: rgba(255, 255, 255, 0.15);
}
.top-nav .dropdown-menu a.router-link-active {
background-color: rgba(255, 255, 255, 0.2);
font-weight: 600;
}
/* Menu Right Side */
.menu-right {
display: flex;
align-items: center;
gap: 10px;
}
/* User Dropdown */
.user-dropdown {
position: relative;
}
.user-icon {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
cursor: pointer;
border-radius: 50%;
transition: background-color 0.2s;
}
.user-icon:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.user-icon svg {
width: 28px;
height: 28px;
color: var(--vt-c-white);
}
.top-nav .user-menu {
right: 0;
left: auto;
min-width: 160px;
}
/* Main Content Area */
.main-content {
flex: 1;
width: 100%;
height: calc(100vh - 60px - 40px); /*
/*margin-top: 10px;
padding: 0; */
padding-bottom: 60px; /* Platz für das fixierte Submenu */
padding-top: 60px; /* Platz für die Top-Navigation */
padding-left: 20px; /* Etwas Innenabstand links */
padding-right: 10px; /* Etwas Innenabstand rechts */
overflow-y: auto;
background-color: var(--color-background);
}
/* Global fullwidth container class for all components */
.fullwidth-container {
width: 100%;
height: 100%;
padding: 20px;
padding-bottom: 80px; /* Extra Platz für das fixierte Submenu */
box-sizing: border-box;
overflow-y: auto;
}
/* Global fullwidth page class for view components */
/*
.fullwidth-page {
width: 100%;
min-height: calc(100vh - 120px);
padding: 20px;
box-sizing: border-box;
}
*/
/* Common Card Layouts */
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 15px;
}
.card:hover {
border-color: #007bff;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
transform: translateY(-2px);
transition: all 0.3s ease;
}
.card h4 {
margin: 0 0 10px 0;
color: #333;
font-size: 1.1rem;
}
.card p {
margin: 5px 0;
color: #495057;
line-height: 1.6;
}
.card ul {
margin: 10px 0;
padding-left: 20px;
}
.card li {
margin: 8px 0;
color: #495057;
line-height: 1.6;
}
.card code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
/* Common Grid Layouts */
.grid-container {
display: grid;
gap: 20px;
width: 100%;
}
.grid-2-columns {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.grid-3-columns {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.grid-4-columns {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
/* Common Header Styles */
.page-header {
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #007bff;
display: flex;
flex-direction: column;
}
.page-header h2,
.page-header h3 {
margin: 0;
color: #333;
font-size: 1.5rem;
}
.back-button {
margin-bottom: 10px;
align-self: flex-start;
}
.section-header {
margin: 30px 0 15px 0;
padding-bottom: 8px;
border-bottom: 1px solid #dee2e6;
}
.section-header h3,
.section-header h4 {
margin: 0;
color: #333;
font-size: 1.2rem;
}
/* Common Button Styles */
.btn {
padding: 8px 16px;
border: 1px solid #dee2e6;
border-radius: 6px;
background: #f8f9fa;
color: #495057;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
text-decoration: none;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.btn-primary {
background: #007bff;
color: white;
border-color: #007bff;
}
.btn-primary:hover {
background: #0056b3;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
}
.btn-secondary {
background: #f8f9fa;
color: #495057;
border-color: #dee2e6;
}
.btn-secondary:hover {
background: #e9ecef;
border-color: #adb5bd;
}
.badge-secondary {
background: #6c757d;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
border-color: #dc3545;
}
.btn-danger:hover {
background: #c82333;
}
.btn-success {
background: #28a745;
color: white;
border-color: #28a745;
}
.btn-success:hover {
background: #218838;
}
/* Status Indicator Styles */
.status-available {
color: #28a745;
font-weight: 600;
}
.status-unavailable {
color: #dc3545;
font-weight: 600;
}
.status-loading {
color: #ffc107;
font-weight: 600;
}
/* Common Form Elements */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #555;
}
.form-control {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-control:focus {
border-color: #007bff;
outline: none;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-row {
display: flex;
gap: 15px;
align-items: flex-start;
}
.form-col {
flex: 1;
min-width: 0;
}
/* Common List Styles */
.list-container {
background: white;
border-radius: 8px;
border: 1px solid #dee2e6;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
transition: all 0.2s ease;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:hover {
background: #f8f9fa;
transform: translateX(2px);
}
.list-item-content {
flex: 1;
}
.list-item-title {
margin: 0 0 6px 0;
color: #333;
font-size: 1.1rem;
font-weight: 600;
}
.list-item-details {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: #666;
}
.list-item-separator {
color: #ccc;
font-weight: normal;
}
.list-item-actions {
display: flex;
gap: 12px;
align-items: center;
flex-shrink: 0;
}
/* Common Badge Styles */
.badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
}
.badge-primary {
background: #007bff;
color: white;
}
.badge-success {
background: #28a745;
color: white;
}
.badge-warning {
background: #ffc107;
color: #212529;
}
.badge-danger {
background: #dc3545;
color: white;
}
.badge-info {
background: #17a2b8;
color: white;
}
.badge-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
/* Common Resource Display */
.resource-display {
display: flex;
gap: 15px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.resource-card {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
flex: 1;
min-width: 160px;
}
.resource-icon {
font-size: 20px;
}
.resource-info {
flex: 1;
}
.resource-label {
font-size: 12px;
color: #6c757d;
font-weight: 500;
}
.resource-amount {
font-size: 16px;
font-weight: bold;
color: #495057;
}
.resource-remaining {
margin-top: 4px;
font-size: 12px;
color: #6c757d;
}
/* Common Progress/Status Indicators */
.progress-badge {
background: #007bff;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: bold;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 8px;
}
.status-active {
background: #28a745;
}
.status-inactive {
background: #6c757d;
}
.status-warning {
background: #ffc107;
}
/* Common Empty States */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-state h3 {
color: #333;
margin-bottom: 10px;
font-size: 1.5rem;
}
.empty-state p {
font-size: 1.1rem;
margin: 0;
}
/* Common Loading States */
.loading-message {
text-align: center;
padding: 20px;
color: #6c757d;
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.top-nav {
flex-direction: column;
height: auto;
}
.top-nav ul {
flex-direction: column;
gap: 10px;
}
}
.cd-table {
width: 100%;
border-collapse: collapse;
position: relative;
margin-top: 1rem;
}
.cd-table thead {
position: sticky;
top: 0;
background: #fff;
color: #000000;
z-index: 1;
}
.cd-table th,
.cd-table td {
padding: 0.5rem;
text-align: left;
border: 1px solid #ddd;
}
.cd-table th {
background-color: #f5f5f5;
font-weight: bold;
color: #000000;
}
.cd-table tr:nth-child(even) {
background-color: #f9f9f9;
color: #000000;
}
/* Data table (used in UserManagementView and other components) */
.data-table {
width: 100%;
border-collapse: collapse;
color: #000000;
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #dee2e6;
color: #000000;
}
.data-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #000000;
}
.data-table tr:hover {
background-color: #f8f9fa;
}
.data-table tr:nth-child(even) {
background-color: #ffffff;
color: #000000;
}
.cd-list {
max-height: calc(100vh - 207px);
flex: 1 1 auto; /* Grow and shrink, take available space */
overflow-y: auto; /* Enable scrolling if content overflows */
min-height: 0; /* Required for Firefox */
overflow-y: auto;
overflow-x: hidden;
}
/* Custom scrollbar styling */
.cd-list::-webkit-scrollbar {
width: 8px;
}
.cd-list::-webkit-scrollbar-track {
background: #f1f1f1;
}
.cd-list::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.cd-list::-webkit-scrollbar-thumb:hover {
background: #555;
}
.cd-view {
color: #333;
background: white;
}
@@ -0,0 +1,64 @@
<template>
<div class="fullwidth-page" style="display:flex;justify-content:center;align-items:center;min-height:100vh;">
<div class="card" style="max-width:400px;width:100%;margin:20px;">
<div class="page-header">
<h2>{{ $t('forgotPassword.title') }}</h2>
<p style="color:#666;font-size:.9em;margin-top:10px;">{{ $t('forgotPassword.description') }}</p>
</div>
<form @submit.prevent="requestReset" v-if="!submitted">
<div class="form-group">
<label for="email">{{ $t('forgotPassword.emailLabel') }}</label>
<input v-model="email" type="email" id="email" class="form-control"
:placeholder="$t('forgotPassword.emailPlaceholder')" required />
</div>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:10px;" :disabled="isLoading">
<span v-if="isLoading">{{ $t('forgotPassword.submitting') }}</span>
<span v-else>{{ $t('forgotPassword.submit') }}</span>
</button>
</form>
<div v-if="submitted" class="badge badge-success" style="width:100%;margin-top:15px;display:block;">
<p><strong>{{ $t('forgotPassword.successTitle') }}</strong></p>
<p>{{ $t('forgotPassword.successInfo') }}</p>
<p>{{ $t('forgotPassword.successHint') }}</p>
</div>
<div v-if="error" class="badge badge-danger" style="width:100%;margin-top:10px;display:block;">{{ error }}</div>
<div style="text-align:center;margin-top:20px;">
<router-link to="/login" class="btn btn-secondary">{{ $t('forgotPassword.backToLogin') }}</router-link>
</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
export default {
name: 'ForgotPasswordForm',
data() {
return { email: '', error: '', submitted: false, isLoading: false }
},
methods: {
async requestReset() {
this.error = ''
this.isLoading = true
try {
await API.post('/password-reset/request', {
email: this.email,
redirect_url: window.location.origin
})
this.submitted = true
} catch (err) {
this.error = err.response?.data?.error || this.$t('forgotPassword.error')
} finally {
this.isLoading = false
}
}
}
}
</script>
<style scoped>/* styles in main.css */</style>
@@ -0,0 +1,72 @@
<template>
<div class="fullwidth-page" style="display:flex;justify-content:center;align-items:center;min-height:100vh;">
<div class="card" style="max-width:400px;width:100%;margin:20px;">
<div class="page-header">
<h2>{{ $t('auth.login') }}</h2>
</div>
<form @submit.prevent="login">
<div class="form-group">
<label for="username">{{ $t('auth.username') }}</label>
<input v-model="username" type="text" id="username" class="form-control"
:placeholder="$t('auth.username')" required />
</div>
<div class="form-group">
<label for="password">{{ $t('auth.password') }}</label>
<input v-model="password" type="password" id="password" class="form-control"
:placeholder="$t('auth.password')" required />
</div>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:10px;">
{{ $t('auth.login') }}
</button>
</form>
<div v-if="error" class="badge badge-danger" style="width:100%;margin-top:15px;text-align:center;display:block;">
{{ error }}
</div>
<div style="text-align:center;margin-top:15px;">
<router-link to="/forgot-password" style="color:#007bff;font-size:.9em;">
Forgot password?
</router-link>
</div>
<div style="text-align:center;margin-top:20px;padding-top:15px;border-top:1px solid #dee2e6;">
<p>{{ $t('auth.dontHaveAccount') }}
<router-link to="/register" class="btn btn-secondary">{{ $t('auth.registerHere') }}</router-link>
</p>
</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
import { useUserStore } from '../stores/userStore'
export default {
name: 'LoginForm',
data() {
return { username: '', password: '', error: '' }
},
methods: {
async login() {
try {
const response = await API.post('/login', {
username: this.username,
password: this.password
})
localStorage.setItem('token', response.data.token)
const userStore = useUserStore()
await userStore.fetchCurrentUser()
window.dispatchEvent(new Event('auth-changed'))
this.$router.push('/dashboard')
} catch {
this.error = 'Invalid credentials'
}
}
}
}
</script>
<style scoped>/* styles in main.css */</style>
+105
View File
@@ -0,0 +1,105 @@
<template>
<nav class="top-nav">
<ul class="menu-left">
<li>
<router-link to="/" active-class="active">{{ $t('menu.Home') }}</router-link>
</li>
<!-- Info dropdown -->
<li class="dropdown" @mouseenter="open('info')" @mouseleave="close('info')">
<span class="dropdown-trigger">{{ $t('menu.Info') }} </span>
<ul v-show="show.info" class="dropdown-menu" @mouseenter="open('info')" @mouseleave="close('info')">
<li><router-link to="/help" @click="closeAll">{{ $t('menu.Help') }}</router-link></li>
<li><router-link to="/system-info" @click="closeAll">{{ $t('menu.SystemInfo') }}</router-link></li>
</ul>
</li>
<!-- App dropdown (authenticated) -->
<li v-if="isLoggedIn" class="dropdown" @mouseenter="open('app')" @mouseleave="close('app')">
<span class="dropdown-trigger">{{ $t('menu.Items') }} </span>
<ul v-show="show.app" class="dropdown-menu" @mouseenter="open('app')" @mouseleave="close('app')">
<li><router-link to="/dashboard" @click="closeAll">{{ $t('menu.Dashboard') }}</router-link></li>
<li><router-link to="/items" @click="closeAll">{{ $t('menu.Items') }}</router-link></li>
</ul>
</li>
<!-- Admin dropdown (maintainer/admin only) -->
<li v-if="isLoggedIn && (isMaintainer || isAdmin)" class="dropdown" @mouseenter="open('admin')" @mouseleave="close('admin')">
<span class="dropdown-trigger">{{ $t('menu.Admin') }} </span>
<ul v-show="show.admin" class="dropdown-menu" @mouseenter="open('admin')" @mouseleave="close('admin')">
<li v-if="isAdmin"><router-link to="/users" @click="closeAll">{{ $t('menu.UserManagement') }}</router-link></li>
</ul>
</li>
<li v-if="!isLoggedIn">
<router-link to="/register" active-class="active">{{ $t('menu.Register') }}</router-link>
</li>
</ul>
<div class="menu-right">
<div v-if="isLoggedIn" class="dropdown user-dropdown" @mouseenter="open('user')" @mouseleave="close('user')">
<span class="dropdown-trigger user-icon" title="User menu">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
</span>
<ul v-show="show.user" class="dropdown-menu user-menu" @mouseenter="open('user')" @mouseleave="close('user')">
<li><router-link to="/profile" @click="closeAll">{{ $t('menu.Profile') }}</router-link></li>
<li><a href="#" @click.prevent="logout">{{ $t('menu.Logout') }}</a></li>
</ul>
</div>
</div>
</nav>
</template>
<script>
import { isLoggedIn, logout } from '../utils/auth'
import { useUserStore } from '../stores/userStore'
export default {
name: 'Menu',
data() {
return {
userStore: null,
show: { info: false, app: false, admin: false, user: false }
}
},
async created() {
this.userStore = useUserStore()
if (isLoggedIn() && !this.userStore.currentUser) {
await this.userStore.fetchCurrentUser()
}
window.addEventListener('auth-changed', this.onAuthChanged)
},
beforeUnmount() {
window.removeEventListener('auth-changed', this.onAuthChanged)
},
computed: {
isLoggedIn() { return isLoggedIn() },
isAdmin() { return this.userStore?.isAdmin || false },
isMaintainer() { return this.userStore?.isMaintainer || false }
},
methods: {
open(menu) { this.show[menu] = true },
close(menu) { this.show[menu] = false },
closeAll() { Object.keys(this.show).forEach(k => (this.show[k] = false)) },
async onAuthChanged() {
if (isLoggedIn()) {
await this.userStore.fetchCurrentUser()
} else {
this.userStore.clearUser()
}
},
logout() {
logout()
this.userStore.clearUser()
window.dispatchEvent(new Event('auth-changed'))
this.$router.push('/')
}
}
}
</script>
<style scoped>
/* All navigation styles are defined in assets/main.css */
</style>
@@ -0,0 +1,79 @@
<template>
<div class="fullwidth-page" style="display:flex;justify-content:center;align-items:center;min-height:100vh;">
<div class="card" style="max-width:400px;width:100%;margin:20px;">
<div class="page-header">
<h2>{{ $t('auth.register') }}</h2>
</div>
<form @submit.prevent="register">
<div class="form-group">
<label for="username">{{ $t('auth.username') }}</label>
<input v-model="username" type="text" id="username" class="form-control"
:placeholder="$t('auth.username')" required />
</div>
<div class="form-group">
<label for="email">{{ $t('auth.email') }}</label>
<input v-model="email" type="email" id="email" class="form-control"
:placeholder="$t('auth.email')" required />
</div>
<div class="form-group">
<label for="password">{{ $t('auth.password') }}</label>
<input v-model="password" type="password" id="password" class="form-control"
:placeholder="$t('auth.password')" required />
</div>
<div class="form-group">
<label for="confirmPassword">{{ $t('auth.confirmPassword') }}</label>
<input v-model="confirmPassword" type="password" id="confirmPassword" class="form-control"
:placeholder="$t('auth.confirmPassword')" required />
</div>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:10px;">
{{ $t('auth.register') }}
</button>
</form>
<div v-if="error" class="badge badge-danger" style="width:100%;margin-top:15px;text-align:center;display:block;">{{ error }}</div>
<div v-if="success" class="badge badge-success" style="width:100%;margin-top:15px;text-align:center;display:block;">{{ success }}</div>
<div style="text-align:center;margin-top:20px;padding-top:15px;border-top:1px solid #dee2e6;">
<p>{{ $t('auth.alreadyHaveAccount') }}
<router-link to="/login" class="btn btn-secondary">{{ $t('auth.loginHere') }}</router-link>
</p>
</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
export default {
name: 'RegisterForm',
data() {
return { username: '', email: '', password: '', confirmPassword: '', error: '', success: '' }
},
methods: {
async register() {
if (this.password !== this.confirmPassword) {
this.error = this.$t('auth.passwordsDontMatch')
return
}
try {
await API.post('/register', {
username: this.username,
password: this.password,
email: this.email
})
this.success = this.$t('auth.registrationSuccess')
this.error = ''
this.password = ''
this.confirmPassword = ''
} catch (err) {
this.error = err.response?.data?.error || this.$t('auth.registrationFailed')
this.success = ''
}
}
}
}
</script>
<style scoped>/* styles in main.css */</style>
@@ -0,0 +1,118 @@
<template>
<div class="fullwidth-page" style="display:flex;justify-content:center;align-items:center;min-height:100vh;">
<div class="card" style="max-width:400px;width:100%;margin:20px;">
<div v-if="isValidating" style="text-align:center;">
<p>{{ $t('common.loading') }}</p>
</div>
<div v-else-if="!isValidToken">
<div class="page-header"><h2>Invalid Reset Link</h2></div>
<div class="badge badge-danger" style="width:100%;margin-top:15px;display:block;">
This reset link is invalid or has expired. Please request a new one.
</div>
<div style="text-align:center;margin-top:20px;">
<router-link to="/forgot-password" class="btn btn-primary">Request new link</router-link>
</div>
</div>
<div v-else>
<div class="page-header"><h2>Set New Password</h2></div>
<form @submit.prevent="resetPassword" v-if="!resetSuccess">
<div class="form-group">
<label for="newPassword">New Password</label>
<input v-model="newPassword" type="password" id="newPassword" class="form-control"
placeholder="Min. 6 characters" required minlength="6" />
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input v-model="confirmPassword" type="password" id="confirmPassword" class="form-control"
placeholder="Repeat password" required minlength="6" />
</div>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:10px;"
:disabled="isResetting || !passwordsMatch">
<span v-if="isResetting">Resetting...</span>
<span v-else>Reset Password</span>
</button>
<div v-if="confirmPassword && !passwordsMatch" class="badge badge-warning"
style="width:100%;margin-top:10px;display:block;font-size:.8em;">
Passwords do not match
</div>
</form>
<div v-if="resetSuccess" class="badge badge-success" style="width:100%;margin-top:15px;display:block;">
<p><strong>Password reset successfully!</strong></p>
<p>You can now log in with your new password.</p>
<div style="margin-top:15px;">
<router-link to="/login" class="btn btn-primary">Go to Login</router-link>
</div>
</div>
<div v-if="error" class="badge badge-danger" style="width:100%;margin-top:15px;display:block;">{{ error }}</div>
</div>
<div v-if="!isValidating && !resetSuccess" style="text-align:center;margin-top:20px;padding-top:15px;border-top:1px solid #dee2e6;">
<router-link to="/login" class="btn btn-secondary">Back to Login</router-link>
</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
export default {
name: 'ResetPasswordForm',
data() {
return {
newPassword: '',
confirmPassword: '',
isValidating: true,
isValidToken: false,
isResetting: false,
resetSuccess: false,
error: '',
token: ''
}
},
computed: {
passwordsMatch() { return this.newPassword === this.confirmPassword }
},
async created() {
this.token = new URLSearchParams(window.location.search).get('token') || ''
if (!this.token) {
this.isValidating = false
return
}
try {
await API.get(`/password-reset/validate/${this.token}`)
this.isValidToken = true
} catch {
this.isValidToken = false
} finally {
this.isValidating = false
}
},
methods: {
async resetPassword() {
if (!this.passwordsMatch) return
this.isResetting = true
this.error = ''
try {
await API.post('/password-reset/reset', {
token: this.token,
new_password: this.newPassword
})
this.resetSuccess = true
} catch (err) {
this.error = err.response?.data?.error || 'Reset failed. Please try again.'
} finally {
this.isResetting = false
}
}
}
}
</script>
<style scoped>/* styles in main.css */</style>
+173
View File
@@ -0,0 +1,173 @@
export default {
common: {
loading: 'Laden...',
cancel: 'Abbrechen',
save: 'Speichern',
edit: 'Bearbeiten',
delete: 'Löschen',
back: 'Zurück',
confirm: 'Bestätigen',
yes: 'Ja',
no: 'Nein',
actions: 'Aktionen',
search: 'Suchen',
create: 'Erstellen',
update: 'Aktualisieren'
},
auth: {
login: 'Anmelden',
register: 'Registrieren',
logout: 'Abmelden',
username: 'Benutzername',
email: 'E-Mail',
password: 'Passwort',
confirmPassword: 'Passwort bestätigen',
passwordsDontMatch: 'Passwörter stimmen nicht überein.',
registrationSuccess: 'Registrierung erfolgreich!',
registrationFailed: 'Registrierung fehlgeschlagen.',
alreadyHaveAccount: 'Bereits ein Konto?',
loginHere: 'Hier anmelden',
dontHaveAccount: 'Noch kein Konto?',
registerHere: 'Hier registrieren'
},
forgotPassword: {
title: 'Passwort zurücksetzen',
description: 'Geben Sie Ihre E-Mail-Adresse ein, um einen Reset-Link zu erhalten.',
emailLabel: 'E-Mail-Adresse',
emailPlaceholder: 'ihre@email.de',
submit: 'Reset-Link senden',
submitting: 'Wird gesendet...',
successTitle: 'E-Mail gesendet!',
successInfo: 'Falls ein Account mit dieser E-Mail existiert, wurde ein Reset-Link gesendet.',
successHint: 'Prüfen Sie Ihre E-Mails und folgen Sie dem Link.',
error: 'Fehler beim Senden. Bitte versuchen Sie es später erneut.',
backToLogin: 'Zurück zum Login'
},
menu: {
Home: 'Start',
Dashboard: 'Dashboard',
Items: 'Elemente',
Help: 'Hilfe',
SystemInfo: 'Systeminfo',
Info: 'Info',
Admin: 'Admin',
Maintenance: 'Wartung',
UserManagement: 'Benutzerverwaltung',
Register: 'Registrieren',
Profile: 'Profil',
Logout: 'Abmelden'
},
landing: {
title: 'Willkommen bei MyApp',
description: 'Ihre Anwendung bereit zur Konfiguration.',
frontendVersion: 'Frontend-Version',
backendVersion: 'Backend-Version',
login: 'Anmelden',
github: 'GitHub',
help: 'Hilfe',
systemInfo: 'Systeminfo',
backendOffline: 'Backend nicht erreichbar...'
},
dashboard: {
title: 'Dashboard',
welcome: 'Willkommen',
items: 'Meine Elemente',
noItems: 'Noch keine Elemente.',
createItem: 'Element erstellen'
},
items: {
title: 'Elemente',
new: 'Neues Element',
name: 'Name',
description: 'Beschreibung',
createSuccess: 'Element erstellt.',
updateSuccess: 'Element aktualisiert.',
deleteSuccess: 'Element gelöscht.',
deleteConfirm: 'Dieses Element wirklich löschen?',
notFound: 'Element nicht gefunden.'
},
profile: {
title: 'Mein Profil',
loading: 'Lade...',
userInfo: 'Benutzerinformationen',
username: 'Benutzername',
displayName: 'Anzeigename',
currentEmail: 'E-Mail',
role: 'Rolle',
language: 'Sprache',
changeDisplayName: 'Anzeigename ändern',
displayNamePlaceholder: 'Anzeigename',
displayNameHelper: 'Max. 30 Zeichen',
updateDisplayName: 'Anzeigename aktualisieren',
changeEmail: 'E-Mail ändern',
newEmail: 'Neue E-Mail',
emailPlaceholder: 'neue@email.de',
updateEmail: 'E-Mail aktualisieren',
changePassword: 'Passwort ändern',
currentPassword: 'Aktuelles Passwort',
newPassword: 'Neues Passwort',
updatePassword: 'Passwort aktualisieren',
changeLanguage: 'Sprache ändern',
selectLanguage: 'Sprache wählen',
updateLanguage: 'Sprache aktualisieren',
updating: 'Wird aktualisiert...',
updateSuccess: 'Erfolgreich aktualisiert.',
updateFailed: 'Aktualisierung fehlgeschlagen.'
},
userManagement: {
title: 'Benutzerverwaltung',
loading: 'Lade Benutzer...',
id: 'ID',
username: 'Benutzername',
displayName: 'Anzeigename',
email: 'E-Mail',
role: 'Rolle',
createdAt: 'Erstellt',
actions: 'Aktionen',
changeRole: 'Rolle ändern',
changePassword: 'Passwort ändern',
delete: 'Löschen',
changeRoleTitle: 'Rolle ändern',
changeRoleFor: 'Rolle für',
selectRole: 'Rolle wählen',
confirmDelete: 'Diesen Benutzer wirklich löschen?',
roles: {
standard: 'Standard',
maintainer: 'Wartung',
admin: 'Admin'
}
},
help: {
title: 'Hilfe',
introduction: 'Willkommen in der Hilfe-Sektion.',
gettingStarted: 'Erste Schritte',
step1Title: '1. Registrieren',
step1Text: 'Erstellen Sie ein Konto über die Registrierungsseite.',
step2Title: '2. Anmelden',
step2Text: 'Melden Sie sich mit Ihren Zugangsdaten an.',
step3Title: '3. Loslegen',
step3Text: 'Erkunden Sie die Anwendung.',
features: 'Funktionen',
featureItemManagement: 'Elementverwaltung',
featureItemManagementText: 'Erstellen, bearbeiten und verwalten Sie Ihre Elemente.',
support: 'Support',
supportText: 'Bei Fragen wenden Sie sich an das Entwicklungsteam.'
},
systemInfo: {
title: 'Systeminfo',
introduction: 'Technische Informationen zur Anwendung.',
versions: 'Versionen',
frontend: 'Frontend',
backend: 'Backend',
version: 'Version',
commit: 'Commit',
status: 'Status',
online: 'Online',
offline: 'Offline',
userCount: 'Benutzer',
dbVersion: 'DB-Version',
technologies: 'Technologien',
infrastructure: 'Infrastruktur',
system: 'System'
}
}
+173
View File
@@ -0,0 +1,173 @@
export default {
common: {
loading: 'Loading...',
cancel: 'Cancel',
save: 'Save',
edit: 'Edit',
delete: 'Delete',
back: 'Back',
confirm: 'Confirm',
yes: 'Yes',
no: 'No',
actions: 'Actions',
search: 'Search',
create: 'Create',
update: 'Update'
},
auth: {
login: 'Login',
register: 'Register',
logout: 'Logout',
username: 'Username',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm Password',
passwordsDontMatch: 'Passwords do not match.',
registrationSuccess: 'Registration successful!',
registrationFailed: 'Registration failed.',
alreadyHaveAccount: 'Already have an account?',
loginHere: 'Login here',
dontHaveAccount: "Don't have an account?",
registerHere: 'Register here'
},
forgotPassword: {
title: 'Reset Password',
description: 'Enter your email address to receive a reset link.',
emailLabel: 'Email address',
emailPlaceholder: 'your@email.com',
submit: 'Send reset link',
submitting: 'Sending...',
successTitle: 'Email sent!',
successInfo: 'If an account with that email exists, a reset link has been sent.',
successHint: 'Check your inbox and follow the link.',
error: 'Error sending email. Please try again later.',
backToLogin: 'Back to Login'
},
menu: {
Home: 'Home',
Dashboard: 'Dashboard',
Items: 'Items',
Help: 'Help',
SystemInfo: 'System Info',
Info: 'Info',
Admin: 'Admin',
Maintenance: 'Maintenance',
UserManagement: 'User Management',
Register: 'Register',
Profile: 'Profile',
Logout: 'Logout'
},
landing: {
title: 'Welcome to MyApp',
description: 'Your application ready to configure.',
frontendVersion: 'Frontend version',
backendVersion: 'Backend version',
login: 'Login',
github: 'GitHub',
help: 'Help',
systemInfo: 'System Info',
backendOffline: 'Backend unreachable...'
},
dashboard: {
title: 'Dashboard',
welcome: 'Welcome',
items: 'My Items',
noItems: 'No items yet.',
createItem: 'Create Item'
},
items: {
title: 'Items',
new: 'New Item',
name: 'Name',
description: 'Description',
createSuccess: 'Item created.',
updateSuccess: 'Item updated.',
deleteSuccess: 'Item deleted.',
deleteConfirm: 'Really delete this item?',
notFound: 'Item not found.'
},
profile: {
title: 'My Profile',
loading: 'Loading...',
userInfo: 'User Information',
username: 'Username',
displayName: 'Display Name',
currentEmail: 'Email',
role: 'Role',
language: 'Language',
changeDisplayName: 'Change Display Name',
displayNamePlaceholder: 'Display name',
displayNameHelper: 'Max. 30 characters',
updateDisplayName: 'Update Display Name',
changeEmail: 'Change Email',
newEmail: 'New Email',
emailPlaceholder: 'new@email.com',
updateEmail: 'Update Email',
changePassword: 'Change Password',
currentPassword: 'Current Password',
newPassword: 'New Password',
updatePassword: 'Update Password',
changeLanguage: 'Change Language',
selectLanguage: 'Select language',
updateLanguage: 'Update Language',
updating: 'Updating...',
updateSuccess: 'Updated successfully.',
updateFailed: 'Update failed.'
},
userManagement: {
title: 'User Management',
loading: 'Loading users...',
id: 'ID',
username: 'Username',
displayName: 'Display Name',
email: 'Email',
role: 'Role',
createdAt: 'Created',
actions: 'Actions',
changeRole: 'Change Role',
changePassword: 'Change Password',
delete: 'Delete',
changeRoleTitle: 'Change Role',
changeRoleFor: 'Change role for',
selectRole: 'Select role',
confirmDelete: 'Really delete this user?',
roles: {
standard: 'Standard',
maintainer: 'Maintainer',
admin: 'Admin'
}
},
help: {
title: 'Help',
introduction: 'Welcome to the help section.',
gettingStarted: 'Getting Started',
step1Title: '1. Register',
step1Text: 'Create an account on the registration page.',
step2Title: '2. Login',
step2Text: 'Sign in with your credentials.',
step3Title: '3. Get started',
step3Text: 'Explore the application.',
features: 'Features',
featureItemManagement: 'Item Management',
featureItemManagementText: 'Create, edit, and manage your items.',
support: 'Support',
supportText: 'For questions, please contact the development team.'
},
systemInfo: {
title: 'System Info',
introduction: 'Technical information about the application.',
versions: 'Versions',
frontend: 'Frontend',
backend: 'Backend',
version: 'Version',
commit: 'Commit',
status: 'Status',
online: 'Online',
offline: 'Offline',
userCount: 'Users',
dbVersion: 'DB Version',
technologies: 'Technologies',
infrastructure: 'Infrastructure',
system: 'System'
}
}
+15
View File
@@ -0,0 +1,15 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { i18n } from './stores/languageStore'
import UtilsPlugin from './utils/utilsPlugin'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.use(UtilsPlugin)
app.mount('#app')
+57
View File
@@ -0,0 +1,57 @@
import { createRouter, createWebHistory } from 'vue-router'
import { isLoggedIn } from '../utils/auth'
import { useUserStore } from '../stores/userStore'
// Static imports for immediately-needed pages
import LandingView from '../views/LandingView.vue'
import LoginView from '../views/LoginView.vue'
import RegisterView from '../views/RegisterView.vue'
import ForgotPasswordView from '../views/ForgotPasswordView.vue'
import ResetPasswordView from '../views/ResetPasswordView.vue'
// Lazy-loaded views (code-split for performance)
const DashboardView = () => import('../views/DashboardView.vue')
const UserProfileView = () => import('../views/UserProfileView.vue')
const UserManagementView = () => import('../views/UserManagementView.vue')
const HelpView = () => import('../views/HelpView.vue')
const SystemInfoView = () => import('../views/SystemInfoView.vue')
const ItemListView = () => import('../views/ItemListView.vue')
const ItemDetailView = () => import('../views/ItemDetailView.vue')
const routes = [
{ path: '/', name: 'Landing', component: LandingView },
{ path: '/login', name: 'Login', component: LoginView },
{ path: '/register', name: 'Register', component: RegisterView },
{ path: '/forgot-password', name: 'ForgotPassword', component: ForgotPasswordView },
{ path: '/reset-password', name: 'ResetPassword', component: ResetPasswordView },
{ path: '/help', name: 'Help', component: HelpView },
{ path: '/system-info', name: 'SystemInfo', component: SystemInfoView },
{ path: '/dashboard', name: 'Dashboard', component: DashboardView, meta: { requiresAuth: true } },
{ path: '/profile', name: 'UserProfile', component: UserProfileView, meta: { requiresAuth: true } },
{ path: '/users', name: 'UserManagement', component: UserManagementView, meta: { requiresAuth: true, requiresAdmin: true } },
{ path: '/items', name: 'ItemList', component: ItemListView, meta: { requiresAuth: true } },
{ path: '/items/:id', name: 'ItemDetail', component: ItemDetailView, props: true, meta: { requiresAuth: true } },
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach(async (to, from, next) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return next({ name: 'Login' })
}
if (to.meta.requiresAdmin) {
const userStore = useUserStore()
if (!userStore.currentUser) {
await userStore.fetchCurrentUser()
}
if (!userStore.isAdmin) {
return next({ name: 'Dashboard' })
}
}
next()
})
export default router
@@ -0,0 +1,25 @@
import { defineStore } from 'pinia'
import { createI18n } from 'vue-i18n'
import de from '@/locales/de'
import en from '@/locales/en'
// i18n is created here so any component or store can import it directly.
export const i18n = createI18n({
legacy: false,
locale: localStorage.getItem('language') || 'en',
fallbackLocale: 'en',
messages: { de, en }
})
export const useLanguageStore = defineStore('language', {
state: () => ({
currentLanguage: localStorage.getItem('language') || 'en'
}),
actions: {
setLanguage(lang) {
this.currentLanguage = lang
i18n.global.locale.value = lang
localStorage.setItem('language', lang)
}
}
})
+42
View File
@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import API from '../utils/api'
import { i18n } from './languageStore'
export const useUserStore = defineStore('user', {
state: () => ({
currentUser: null,
isLoading: false
}),
getters: {
isAuthenticated: (state) => !!state.currentUser,
userRole: (state) => state.currentUser?.role || 'standard',
isAdmin: (state) => state.currentUser?.role === 'admin',
isMaintainer: (state) => ['maintainer', 'admin'].includes(state.currentUser?.role),
isStandardUser: (state) => !!state.currentUser
},
actions: {
async fetchCurrentUser() {
this.isLoading = true
try {
const response = await API.get('/api/user/profile')
const profile = { ...response.data }
profile.display_name = profile.display_name || profile.username
this.currentUser = profile
if (profile.preferred_language) {
i18n.global.locale.value = profile.preferred_language
localStorage.setItem('language', profile.preferred_language)
}
} catch {
this.currentUser = null
} finally {
this.isLoading = false
}
},
clearUser() {
this.currentUser = null
}
}
})
+29
View File
@@ -0,0 +1,29 @@
import axios from 'axios'
// Axios instance with automatic auth header injection.
const API = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8180'
})
API.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
API.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
}
return Promise.reject(error)
}
)
export default API
+7
View File
@@ -0,0 +1,7 @@
export function isLoggedIn() {
return !!localStorage.getItem('token')
}
export function logout() {
localStorage.removeItem('token')
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Date and value formatting utilities.
*/
export function formatDate(dateString) {
if (!dateString) return ''
return new Date(dateString).toLocaleDateString()
}
export function formatDateTime(dateString) {
if (!dateString) return ''
return new Date(dateString).toLocaleString()
}
export function formatRelativeDate(dateString) {
if (!dateString) return ''
const diff = Date.now() - new Date(dateString).getTime()
const days = Math.floor(diff / 86400000)
if (days === 0) return 'Today'
if (days === 1) return 'Yesterday'
return `${days} days ago`
}
export function safeValue(value, fallback = '') {
return value !== null && value !== undefined ? value : fallback
}
export function capitalize(str) {
if (!str) return ''
return str.charAt(0).toUpperCase() + str.slice(1)
}
@@ -0,0 +1,20 @@
/**
* Vue Plugin that registers global utility functions on all components.
*
* Usage in components:
* this.$formatDate(dateString)
* this.$safeValue(value, 'fallback')
*/
import { formatDate, formatDateTime, formatRelativeDate, safeValue, capitalize } from './dateUtils'
export default {
install(app) {
app.config.globalProperties.$formatDate = formatDate
app.config.globalProperties.$formatDateTime = formatDateTime
app.config.globalProperties.$formatRelativeDate = formatRelativeDate
app.config.globalProperties.$safeValue = safeValue
app.config.globalProperties.$capitalize = capitalize
app.provide('utils', { formatDate, formatDateTime, formatRelativeDate, safeValue, capitalize })
}
}
+7
View File
@@ -0,0 +1,7 @@
// Application version information.
// Update VERSION when cutting a release.
export const VERSION = '0.1.0'
export const GIT_COMMIT = import.meta.env.VITE_GIT_COMMIT || 'unknown'
export function getVersion() { return VERSION }
export function getGitCommit() { return GIT_COMMIT }
@@ -0,0 +1,56 @@
<template>
<div class="fullwidth-container">
<div class="page-header">
<h2>{{ $t('dashboard.title') }}</h2>
<p v-if="user">{{ $t('dashboard.welcome') }}, {{ user.display_name || user.username }}!</p>
</div>
<div class="card">
<h3>{{ $t('dashboard.items') }}</h3>
<div v-if="items.length === 0" class="empty-state">
<p>{{ $t('dashboard.noItems') }}</p>
</div>
<div v-else class="list-container">
<div v-for="item in items" :key="item.id" class="list-item">
<router-link :to="`/items/${item.id}`" class="list-item-content">
<h4 class="list-item-title">{{ item.name }}</h4>
<p class="list-item-description">{{ item.description }}</p>
</router-link>
</div>
</div>
<div style="margin-top:15px;">
<router-link to="/items" class="btn btn-primary">{{ $t('dashboard.createItem') }}</router-link>
</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
import { useUserStore } from '../stores/userStore'
export default {
name: 'DashboardView',
data() {
return { items: [], user: null }
},
async created() {
const userStore = useUserStore()
if (!userStore.currentUser) await userStore.fetchCurrentUser()
this.user = userStore.currentUser
await this.loadItems()
},
methods: {
async loadItems() {
try {
const response = await API.get('/api/items')
this.items = response.data || []
} catch (err) {
console.error('Failed to load items:', err)
}
}
}
}
</script>
<style scoped>/* styles in main.css */</style>
@@ -0,0 +1,8 @@
<template>
<ForgotPasswordForm />
</template>
<script>
import ForgotPasswordForm from '../components/ForgotPasswordForm.vue'
export default { components: { ForgotPasswordForm } }
</script>
+53
View File
@@ -0,0 +1,53 @@
<template>
<div class="fullwidth-container">
<div class="page-header">
<h2>{{ $t('help.title') }}</h2>
</div>
<div class="card">
<p>{{ $t('help.introduction') }}</p>
</div>
<div class="section-header"><h3>{{ $t('help.gettingStarted') }}</h3></div>
<div class="grid-container grid-3-columns">
<div class="card">
<h4>1. {{ $t('help.step1Title') }}</h4>
<p>{{ $t('help.step1Text') }}</p>
</div>
<div class="card">
<h4>2. {{ $t('help.step2Title') }}</h4>
<p>{{ $t('help.step2Text') }}</p>
</div>
<div class="card">
<h4>3. {{ $t('help.step3Title') }}</h4>
<p>{{ $t('help.step3Text') }}</p>
</div>
</div>
<div class="section-header"><h3>{{ $t('help.features') }}</h3></div>
<div class="card">
<ul>
<li>{{ $t('help.feature1') }}</li>
<li>{{ $t('help.feature2') }}</li>
<li>{{ $t('help.feature3') }}</li>
<li>{{ $t('help.feature4') }}</li>
</ul>
</div>
<div class="section-header"><h3>{{ $t('help.support') }}</h3></div>
<div class="card">
<p>{{ $t('help.supportText') }}</p>
</div>
</div>
</template>
<script>
export default {
name: 'HelpView'
}
</script>
<style scoped>/* styles in main.css */</style>
@@ -0,0 +1,103 @@
<template>
<div class="fullwidth-container">
<div class="page-header">
<button @click="$router.back()" class="btn btn-secondary back-button"> {{ $t('common.back') }}</button>
<h2>{{ item ? item.name : $t('common.loading') }}</h2>
</div>
<div v-if="loading" class="card">{{ $t('common.loading') }}</div>
<div v-else-if="!item" class="card">
<p class="badge badge-danger">{{ $t('items.notFound') }}</p>
</div>
<div v-else class="card">
<!-- View mode -->
<div v-if="!editing">
<p><strong>{{ $t('items.name') }}:</strong> {{ item.name }}</p>
<p><strong>{{ $t('items.description') }}:</strong> {{ item.description }}</p>
<div style="margin-top:15px;display:flex;gap:10px;">
<button class="btn btn-primary" @click="startEdit">{{ $t('common.edit') }}</button>
<button class="btn btn-secondary" @click="confirmDelete">{{ $t('common.delete') }}</button>
</div>
</div>
<!-- Edit mode -->
<div v-else>
<form @submit.prevent="saveItem">
<div class="form-group">
<label>{{ $t('items.name') }}</label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>{{ $t('items.description') }}</label>
<textarea v-model="form.description" class="form-control" rows="4" />
</div>
<button type="submit" class="btn btn-primary">{{ $t('common.save') }}</button>
<button type="button" class="btn btn-secondary" @click="editing = false">{{ $t('common.cancel') }}</button>
</form>
</div>
<div v-if="message" class="badge badge-success" style="margin-top:10px;">{{ message }}</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
export default {
name: 'ItemDetailView',
props: ['id'],
data() {
return {
item: null,
loading: true,
editing: false,
form: { name: '', description: '' },
message: ''
}
},
async created() {
await this.loadItem()
},
methods: {
async loadItem() {
this.loading = true
try {
const response = await API.get(`/api/items/${this.id}`)
this.item = response.data
} catch {
this.item = null
} finally {
this.loading = false
}
},
startEdit() {
this.form = { name: this.item.name, description: this.item.description }
this.editing = true
},
async saveItem() {
try {
const response = await API.put(`/api/items/${this.id}`, this.form)
this.item = response.data
this.editing = false
this.message = this.$t('items.updateSuccess')
} catch (err) {
console.error('Failed to update item:', err)
}
},
async confirmDelete() {
if (!confirm(this.$t('items.deleteConfirm'))) return
try {
await API.delete(`/api/items/${this.id}`)
this.$router.push('/items')
} catch (err) {
console.error('Failed to delete item:', err)
}
}
}
}
</script>
<style scoped>/* styles in main.css */</style>
@@ -0,0 +1,84 @@
<template>
<div class="fullwidth-container">
<div class="page-header">
<h2>{{ $t('items.title') }}</h2>
<button @click="showCreate = true" class="btn btn-primary">{{ $t('items.new') }}</button>
</div>
<!-- Create form -->
<div v-if="showCreate" class="card">
<h3>{{ $t('items.new') }}</h3>
<form @submit.prevent="createItem">
<div class="form-group">
<label>{{ $t('items.name') }}</label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>{{ $t('items.description') }}</label>
<textarea v-model="form.description" class="form-control" rows="3" />
</div>
<button type="submit" class="btn btn-primary">{{ $t('common.create') }}</button>
<button type="button" class="btn btn-secondary" @click="showCreate = false; resetForm()">
{{ $t('common.cancel') }}
</button>
</form>
</div>
<!-- Items list -->
<div v-if="items.length === 0 && !showCreate" class="empty-state">
<p>{{ $t('dashboard.noItems') }}</p>
</div>
<div class="list-container">
<div v-for="item in items" :key="item.id" class="list-item">
<router-link :to="`/items/${item.id}`" class="list-item-content">
<h4 class="list-item-title">{{ item.name }}</h4>
<p class="list-item-description">{{ item.description }}</p>
</router-link>
</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
export default {
name: 'ItemListView',
data() {
return {
items: [],
showCreate: false,
form: { name: '', description: '' }
}
},
async created() {
await this.loadItems()
},
methods: {
async loadItems() {
try {
const response = await API.get('/api/items')
this.items = response.data || []
} catch (err) {
console.error('Failed to load items:', err)
}
},
async createItem() {
try {
await API.post('/api/items', this.form)
this.showCreate = false
this.resetForm()
await this.loadItems()
} catch (err) {
console.error('Failed to create item:', err)
}
},
resetForm() {
this.form = { name: '', description: '' }
}
}
}
</script>
<style scoped>/* styles in main.css */</style>
@@ -0,0 +1,82 @@
<template>
<div class="landing-page">
<div class="landing-content">
<div class="info-container">
<h1>{{ $t('landing.title') }}</h1>
<p class="description">{{ $t('landing.description') }}</p>
<div class="version-info">
<p>{{ $t('landing.frontendVersion') }}: {{ frontendVersion }}</p>
<p>{{ $t('landing.backendVersion') }}: {{ backendStatus }}</p>
</div>
<div class="action-links">
<router-link to="/login" class="btn btn-primary" :class="{ disabled: !isBackendAvailable }">
{{ $t('landing.login') }}
</router-link>
</div>
<div class="quick-links">
<router-link to="/help" class="quick-link">{{ $t('landing.help') }}</router-link>
<router-link to="/system-info" class="quick-link">{{ $t('landing.systemInfo') }}</router-link>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Component-specific styles in main.css */
</style>
<script>
import axios from 'axios'
import { getVersion } from '../version'
export default {
name: 'LandingView',
data() {
return {
frontendVersion: getVersion(),
backendStatus: this.$t('common.loading'),
retryInterval: null,
retries: 0,
maxRetries: 24
}
},
computed: {
isBackendAvailable() {
return this.backendStatus !== this.$t('common.loading') &&
this.backendStatus !== this.$t('landing.backendOffline')
}
},
mounted() {
this.pollBackend()
},
beforeUnmount() {
if (this.retryInterval) clearInterval(this.retryInterval)
},
methods: {
async pollBackend() {
const apiURL = import.meta.env.VITE_API_URL || 'http://localhost:8180'
const tryFetch = async () => {
try {
const res = await axios.get(`${apiURL}/api/public/version`)
this.backendStatus = res.data.version
clearInterval(this.retryInterval)
} catch {
this.retries++
if (this.retries >= this.maxRetries) {
this.backendStatus = this.$t('landing.backendOffline')
clearInterval(this.retryInterval)
}
}
}
await tryFetch()
if (!this.isBackendAvailable) {
this.retryInterval = setInterval(tryFetch, 5000)
}
}
}
}
</script>
@@ -0,0 +1,8 @@
<template>
<LoginForm />
</template>
<script>
import LoginForm from '../components/LoginForm.vue'
export default { components: { LoginForm } }
</script>
@@ -0,0 +1,8 @@
<template>
<RegisterForm />
</template>
<script>
import RegisterForm from '../components/RegisterForm.vue'
export default { components: { RegisterForm } }
</script>
@@ -0,0 +1,8 @@
<template>
<ResetPasswordForm />
</template>
<script>
import ResetPasswordForm from '../components/ResetPasswordForm.vue'
export default { components: { ResetPasswordForm } }
</script>
@@ -0,0 +1,108 @@
<template>
<div class="fullwidth-container">
<div class="page-header" style="flex-direction:row;align-items:center;gap:10px;">
<button @click="$router.back()" class="btn btn-secondary"> {{ $t('common.back') }}</button>
<h2 style="margin:0;">{{ $t('systemInfo.title') }}</h2>
</div>
<div class="card">
<p>{{ $t('systemInfo.introduction') }}</p>
</div>
<div class="section-header"><h3>{{ $t('systemInfo.versions') }}</h3></div>
<div class="grid-container grid-2-columns">
<div class="card">
<h4>{{ $t('systemInfo.frontend') }}</h4>
<p><strong>{{ $t('systemInfo.version') }}:</strong> {{ frontendVersion }}</p>
</div>
<div class="card">
<h4>{{ $t('systemInfo.backend') }}</h4>
<p><strong>{{ $t('systemInfo.version') }}:</strong> {{ backendVersion }}</p>
<p><strong>{{ $t('systemInfo.status') }}:</strong>
<span :class="statusClass">{{ statusText }}</span>
</p>
</div>
<div class="card">
<h4>{{ $t('systemInfo.system') }}</h4>
<p><strong>{{ $t('systemInfo.userCount') }}:</strong> {{ userCount }}</p>
<p><strong>{{ $t('systemInfo.dbVersion') }}:</strong> {{ dbVersion || 'N/A' }}</p>
</div>
</div>
<div class="section-header"><h3>{{ $t('systemInfo.technologies') }}</h3></div>
<div class="grid-container grid-3-columns">
<div class="card">
<h4>Frontend</h4>
<ul>
<li>Vue 3</li><li>Vite</li><li>Vue Router</li><li>Pinia</li><li>Axios</li><li>Vue i18n</li>
</ul>
</div>
<div class="card">
<h4>Backend</h4>
<ul>
<li>Go</li><li>Gin Framework</li><li>GORM</li><li>MariaDB / SQLite</li><li>JWT Auth</li>
</ul>
</div>
<div class="card">
<h4>{{ $t('systemInfo.infrastructure') }}</h4>
<ul>
<li>Docker</li><li>Docker Compose</li><li>Air (Hot Reload)</li><li>nginx</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
import { getVersion } from '../version'
export default {
name: 'SystemInfoView',
data() {
return {
frontendVersion: getVersion(),
backendVersion: 'Loading...',
userCount: 'Loading...',
dbVersion: 'Loading...',
isBackendAvailable: false
}
},
computed: {
statusClass() {
if (this.backendVersion === 'Loading...') return 'status-loading'
return this.isBackendAvailable ? 'status-available' : 'status-unavailable'
},
statusText() {
if (this.backendVersion === 'Loading...') return this.$t('systemInfo.statusLoading')
return this.isBackendAvailable ? this.$t('systemInfo.statusAvailable') : this.$t('systemInfo.statusUnavailable')
}
},
mounted() {
this.fetchBackendInfo()
},
methods: {
async fetchBackendInfo() {
try {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8180'
const response = await axios.get(`${apiUrl}/api/public/systeminfo`)
if (response.data) {
this.backendVersion = response.data.version || 'Unknown'
this.userCount = response.data.userCount ?? 'Unknown'
this.dbVersion = response.data.dbVersion || 'Unknown'
this.isBackendAvailable = true
}
} catch {
this.backendVersion = 'Unavailable'
this.userCount = 'N/A'
this.dbVersion = 'N/A'
this.isBackendAvailable = false
}
}
}
}
</script>
<style scoped>/* styles in main.css */</style>
@@ -0,0 +1,211 @@
<template>
<div class="fullwidth-container">
<div class="page-header">
<h2>{{ $t('userManagement.title') }}</h2>
</div>
<div v-if="isLoading" class="loading-message">{{ $t('userManagement.loading') }}</div>
<div v-else-if="error" class="badge badge-danger">{{ error }}</div>
<div v-else class="card">
<table class="data-table">
<thead>
<tr>
<th>{{ $t('userManagement.id') }}</th>
<th>{{ $t('userManagement.username') }}</th>
<th>{{ $t('userManagement.displayName') }}</th>
<th>{{ $t('userManagement.email') }}</th>
<th>{{ $t('userManagement.role') }}</th>
<th>{{ $t('userManagement.createdAt') }}</th>
<th>{{ $t('userManagement.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.display_name }}</td>
<td>{{ user.email }}</td>
<td><span :class="`badge-role-${user.role}`">{{ $t(`userManagement.roles.${user.role}`) }}</span></td>
<td>{{ formatDate(user.created_at) }}</td>
<td>
<button @click="openRoleDialog(user)" class="btn btn-secondary btn-sm" :disabled="user.id === currentUserId">
{{ $t('userManagement.changeRole') }}
</button>
<button @click="openPasswordDialog(user)" class="btn btn-sm">
{{ $t('userManagement.changePassword') }}
</button>
<button @click="confirmDeleteUser(user)" class="btn btn-sm" :disabled="user.id === currentUserId">
{{ $t('userManagement.delete') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Role Change Dialog -->
<div v-if="showRoleDialog" class="modal-overlay" @click.self="showRoleDialog = false">
<div class="modal-content">
<div class="modal-header"><h3>{{ $t('userManagement.changeRoleTitle') }}</h3></div>
<div class="modal-body">
<p>{{ $t('userManagement.changeRoleFor') }}: <strong>{{ selectedUser.display_name || selectedUser.username }}</strong></p>
<div class="form-group">
<label>{{ $t('userManagement.selectRole') }}</label>
<select v-model="newRole" class="form-control">
<option value="standard">{{ $t('userManagement.roles.standard') }}</option>
<option value="maintainer">{{ $t('userManagement.roles.maintainer') }}</option>
<option value="admin">{{ $t('userManagement.roles.admin') }}</option>
</select>
</div>
</div>
<div class="modal-footer">
<button @click="updateUserRole" class="btn btn-primary">{{ $t('userManagement.save') }}</button>
<button @click="showRoleDialog = false" class="btn btn-secondary">{{ $t('userManagement.cancel') }}</button>
</div>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<div v-if="showDeleteDialog" class="modal-overlay" @click.self="showDeleteDialog = false">
<div class="modal-content">
<div class="modal-header"><h3>{{ $t('userManagement.deleteUserTitle') }}</h3></div>
<div class="modal-body">
<p>{{ $t('userManagement.deleteConfirm') }}: <strong>{{ selectedUser.display_name || selectedUser.username }}</strong>?</p>
<p class="badge badge-warning">{{ $t('userManagement.deleteWarning') }}</p>
</div>
<div class="modal-footer">
<button @click="deleteUser" class="btn btn-danger">{{ $t('userManagement.delete') }}</button>
<button @click="showDeleteDialog = false" class="btn btn-secondary">{{ $t('userManagement.cancel') }}</button>
</div>
</div>
</div>
<!-- Change Password Dialog -->
<div v-if="showPasswordDialog" class="modal-overlay" @click.self="showPasswordDialog = false">
<div class="modal-content">
<div class="modal-header"><h3>{{ $t('userManagement.changePasswordTitle') }}</h3></div>
<div class="modal-body">
<p>{{ $t('userManagement.changePasswordFor') }}: <strong>{{ selectedUser.display_name || selectedUser.username }}</strong></p>
<div class="form-group">
<label>{{ $t('userManagement.newPassword') }}</label>
<input v-model="newPassword" type="password" class="form-control" minlength="6" />
</div>
<div class="form-group">
<label>{{ $t('userManagement.confirmPassword') }}</label>
<input v-model="confirmPassword" type="password" class="form-control" minlength="6" />
</div>
</div>
<div class="modal-footer">
<button @click="changeUserPassword" class="btn btn-primary">{{ $t('userManagement.save') }}</button>
<button @click="showPasswordDialog = false" class="btn btn-secondary">{{ $t('userManagement.cancel') }}</button>
</div>
</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
import { useUserStore } from '../stores/userStore'
export default {
name: 'UserManagementView',
data() {
return {
users: [],
isLoading: false,
error: null,
showRoleDialog: false,
showDeleteDialog: false,
showPasswordDialog: false,
selectedUser: null,
newRole: '',
newPassword: '',
confirmPassword: ''
}
},
computed: {
currentUserId() {
return useUserStore().currentUser?.id
}
},
async created() {
await this.loadUsers()
},
methods: {
async loadUsers() {
this.isLoading = true
this.error = null
try {
const response = await API.get('/api/users')
this.users = response.data
} catch {
this.error = this.$t('userManagement.loadError')
} finally {
this.isLoading = false
}
},
openRoleDialog(user) {
this.selectedUser = user
this.newRole = user.role
this.showRoleDialog = true
},
async updateUserRole() {
try {
await API.put(`/api/users/${this.selectedUser.id}/role`, { role: this.newRole })
this.showRoleDialog = false
await this.loadUsers()
} catch {
this.error = this.$t('userManagement.updateError')
}
},
confirmDeleteUser(user) {
this.selectedUser = user
this.showDeleteDialog = true
},
async deleteUser() {
try {
await API.delete(`/api/users/${this.selectedUser.id}`)
this.showDeleteDialog = false
await this.loadUsers()
} catch {
this.error = this.$t('userManagement.deleteError')
}
},
openPasswordDialog(user) {
this.selectedUser = user
this.newPassword = ''
this.confirmPassword = ''
this.showPasswordDialog = true
},
async changeUserPassword() {
if (!this.newPassword || this.newPassword.length < 6) { this.error = this.$t('userManagement.passwordTooShort'); return }
if (this.newPassword !== this.confirmPassword) { this.error = this.$t('userManagement.passwordMismatch'); return }
try {
await API.put(`/api/users/${this.selectedUser.id}/password`, { new_password: this.newPassword })
this.showPasswordDialog = false
this.error = null
} catch {
this.error = this.$t('userManagement.passwordChangeError')
}
},
formatDate(dateString) {
return this.$formatDate ? this.$formatDate(dateString) : new Date(dateString).toLocaleDateString()
}
}
}
</script>
<style scoped>
.btn-sm { padding: 5px 10px; font-size: 0.875rem; margin-right: 5px; }
.badge-role-standard { background: #6c757d; color: white; padding: 4px 8px; border-radius: 4px; font-size: 0.875rem; }
.badge-role-maintainer { background: #0dcaf0; color: white; padding: 4px 8px; border-radius: 4px; font-size: 0.875rem; }
.badge-role-admin { background: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-size: 0.875rem; }
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 1000; }
.modal-content { background: white; border-radius: 8px; max-width: 500px; width: 90%; overflow: hidden; }
.modal-header { padding: 20px; border-bottom: 1px solid #dee2e6; }
.modal-header h3 { margin: 0; }
.modal-body { padding: 20px; }
.modal-footer { padding: 15px 20px; border-top: 1px solid #dee2e6; display: flex; justify-content: flex-end; gap: 10px; }
</style>
@@ -0,0 +1,192 @@
<template>
<div class="fullwidth-container">
<div class="page-header">
<h2>{{ $t('profile.title') }}</h2>
</div>
<div v-if="loading" class="loading-message">{{ $t('profile.loading') }}</div>
<div v-else class="profile-sections">
<!-- User Info -->
<div class="card">
<h3>{{ $t('profile.userInfo') }}</h3>
<div class="info-row"><label>{{ $t('profile.displayName') }}:</label><span>{{ userProfile.display_name || userProfile.username }}</span></div>
<div class="info-row"><label>{{ $t('profile.username') }}:</label><span>{{ userProfile.username }}</span></div>
<div class="info-row"><label>{{ $t('profile.currentEmail') }}:</label><span>{{ userProfile.email }}</span></div>
<div class="info-row">
<label>{{ $t('profile.role') }}:</label>
<span :class="`badge-role-${userProfile.role}`">{{ $t(`userManagement.roles.${userProfile.role}`) }}</span>
</div>
<div class="info-row"><label>{{ $t('profile.language') }}:</label><span>{{ userProfile.preferred_language === 'de' ? 'Deutsch' : 'English' }}</span></div>
</div>
<!-- Change Display Name -->
<div class="card">
<h3>{{ $t('profile.changeDisplayName') }}</h3>
<form @submit.prevent="updateDisplayName">
<div class="form-group">
<label>{{ $t('profile.displayName') }}</label>
<input v-model="displayNameForm.newDisplayName" type="text" class="form-control" maxlength="30" />
<small>{{ $t('profile.displayNameHelper') }}</small>
</div>
<button type="submit" :disabled="isUpdating" class="btn btn-primary">{{ $t('profile.updateDisplayName') }}</button>
</form>
</div>
<!-- Change Language -->
<div class="card">
<h3>{{ $t('profile.changeLanguage') }}</h3>
<form @submit.prevent="updateLanguage">
<div class="form-group">
<label>{{ $t('profile.selectLanguage') }}</label>
<select v-model="languageForm.selectedLanguage" class="form-control">
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
<button type="submit" :disabled="isUpdating" class="btn btn-primary">{{ $t('profile.updateLanguage') }}</button>
</form>
</div>
<!-- Change Email -->
<div class="card">
<h3>{{ $t('profile.changeEmail') }}</h3>
<form @submit.prevent="updateEmail">
<div class="form-group">
<label>{{ $t('profile.newEmail') }}</label>
<input v-model="emailForm.newEmail" type="email" class="form-control" required />
</div>
<button type="submit" :disabled="isUpdating" class="btn btn-primary">{{ $t('profile.updateEmail') }}</button>
</form>
</div>
<!-- Change Password -->
<div class="card">
<h3>{{ $t('profile.changePassword') }}</h3>
<form @submit.prevent="updatePassword">
<div class="form-group">
<label>{{ $t('profile.currentPassword') }}</label>
<input v-model="passwordForm.currentPassword" type="password" class="form-control" required />
</div>
<div class="form-group">
<label>{{ $t('profile.newPassword') }}</label>
<input v-model="passwordForm.newPassword" type="password" class="form-control" minlength="6" required />
</div>
<div class="form-group">
<label>{{ $t('profile.confirmPassword') }}</label>
<input v-model="passwordForm.confirmPassword" type="password" class="form-control" minlength="6" required />
</div>
<button type="submit" :disabled="isUpdating" class="btn btn-primary">{{ $t('profile.updatePassword') }}</button>
</form>
</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
import { useUserStore } from '../stores/userStore'
export default {
name: 'UserProfileView',
data() {
return {
loading: true,
isUpdating: false,
userProfile: { username: '', display_name: '', email: '', role: 'standard', preferred_language: 'en' },
displayNameForm: { newDisplayName: '' },
languageForm: { selectedLanguage: 'en' },
emailForm: { newEmail: '' },
passwordForm: { currentPassword: '', newPassword: '', confirmPassword: '' }
}
},
async created() {
await this.loadProfile()
},
methods: {
async loadProfile() {
this.loading = true
try {
const response = await API.get('/api/user/profile')
this.userProfile = response.data
this.emailForm.newEmail = this.userProfile.email
this.languageForm.selectedLanguage = this.userProfile.preferred_language || 'en'
this.displayNameForm.newDisplayName = this.userProfile.display_name || ''
} catch (error) {
console.error('Failed to load profile:', error)
alert(this.$t('profile.loadError') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.loading = false
}
},
async updateDisplayName() {
if (this.displayNameForm.newDisplayName.length > 30) {
alert(this.$t('profile.displayNameTooLong')); return
}
this.isUpdating = true
try {
const response = await API.put('/api/user/display-name', { display_name: this.displayNameForm.newDisplayName })
this.userProfile.display_name = response.data.display_name || this.userProfile.username
const userStore = useUserStore()
if (userStore.currentUser) userStore.currentUser.display_name = this.userProfile.display_name
} catch (error) {
alert(error.response?.data?.error || this.$t('profile.displayNameUpdateError'))
} finally {
this.isUpdating = false
}
},
async updateEmail() {
if (this.emailForm.newEmail === this.userProfile.email) { alert(this.$t('profile.emailUnchanged')); return }
this.isUpdating = true
try {
const response = await API.put('/api/user/email', { email: this.emailForm.newEmail })
this.userProfile.email = response.data.email
} catch (error) {
const msg = error.response?.data?.error || ''
alert(msg.includes('already in use') ? this.$t('profile.emailInUse') : this.$t('profile.emailUpdateError') + ': ' + msg)
} finally {
this.isUpdating = false
}
},
async updatePassword() {
if (this.passwordForm.newPassword.length < 6) { alert(this.$t('profile.passwordTooShort')); return }
if (this.passwordForm.newPassword !== this.passwordForm.confirmPassword) { alert(this.$t('profile.passwordMismatch')); return }
this.isUpdating = true
try {
await API.put('/api/user/password', { current_password: this.passwordForm.currentPassword, new_password: this.passwordForm.newPassword })
alert(this.$t('profile.passwordUpdateSuccess'))
this.passwordForm = { currentPassword: '', newPassword: '', confirmPassword: '' }
} catch (error) {
const msg = error.response?.data?.error || ''
alert(msg.includes('incorrect') ? this.$t('profile.currentPasswordIncorrect') : this.$t('profile.passwordUpdateError') + ': ' + msg)
} finally {
this.isUpdating = false
}
},
async updateLanguage() {
this.isUpdating = true
try {
const response = await API.put('/api/user/language', { language: this.languageForm.selectedLanguage })
this.userProfile.preferred_language = response.data.language
this.$i18n.locale = response.data.language
localStorage.setItem('language', response.data.language)
alert(this.$t('profile.languageUpdateSuccess'))
} catch (error) {
alert(this.$t('profile.languageUpdateError') + ': ' + (error.response?.data?.error || error.message))
} finally {
this.isUpdating = false
}
}
}
}
</script>
<style scoped>
.profile-sections { display: flex; flex-direction: column; gap: 20px; max-width: 800px; }
.info-row { display: flex; padding: 8px 0; border-bottom: 1px solid #eee; }
.info-row:last-child { border-bottom: none; }
.info-row label { font-weight: bold; width: 200px; color: #6c757d; }
.badge-role-standard { background: #6c757d; color: white; padding: 4px 12px; border-radius: 4px; font-size: 0.875rem; }
.badge-role-maintainer { background: #0dcaf0; color: white; padding: 4px 12px; border-radius: 4px; font-size: 0.875rem; }
.badge-role-admin { background: #dc3545; color: white; padding: 4px 12px; border-radius: 4px; font-size: 0.875rem; }
</style>
+16
View File
@@ -0,0 +1,16 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig({
plugins: [vue(), vueDevTools()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
host: '0.0.0.0'
}
})