app template
This commit is contained in:
+128
@@ -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
@@ -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
@@ -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">×</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
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
tmp/
|
||||
testdata/*.db
|
||||
*.db
|
||||
coverage.out
|
||||
server
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{},
|
||||
)
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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...) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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;"]
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem('token')
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user