Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24d0d0bf53 | |||
| 0e8d5aef85 | |||
| b1238b7bb8 | |||
| d8a3c03554 |
@@ -1,99 +0,0 @@
|
||||
# tOS Backend Architecture Memory
|
||||
|
||||
## Project Structure
|
||||
- **Location**: `/home/mehmed/Entwicklung/githubProjekte/tOS/apps/api/`
|
||||
- **Framework**: NestJS 10.3.x with TypeScript strict mode
|
||||
- **ORM**: Prisma 5.8.x with PostgreSQL
|
||||
- **Auth**: JWT-based with Keycloak integration support
|
||||
|
||||
## Key Conventions
|
||||
- **Language**: English for all code/comments
|
||||
- **Files**: kebab-case, **Variables**: camelCase
|
||||
- **API Prefix**: `/api/v1/`
|
||||
- **Global Guards**: JwtAuthGuard -> RolesGuard -> PermissionsGuard
|
||||
- **IDs**: CUID, **Soft Delete**: `isActive` boolean
|
||||
|
||||
## Security - Encryption (CRITICAL)
|
||||
- `EncryptionService` in `src/common/services/` (AES-256-GCM)
|
||||
- **Encrypted fields in Employee model:**
|
||||
- `salary` - Stored as encrypted String (not Decimal!)
|
||||
- `bankAccount` - Stored as encrypted JSON string
|
||||
- Access via `findOne(id, includeSensitive=true)` for decryption
|
||||
- Config: `ENCRYPTION_KEY` env variable (required in production)
|
||||
|
||||
## Auth Pattern
|
||||
- Routes protected by default via global JwtAuthGuard
|
||||
- `@Public()` for open endpoints
|
||||
- `@Roles('admin', 'hr-manager')` for role-based access
|
||||
- `@RequirePermissions(Permission.USERS_CREATE)` for fine-grained
|
||||
- `@CurrentUser()` to get JWT payload
|
||||
|
||||
## Available Roles
|
||||
admin, hr-manager, team-lead, employee
|
||||
|
||||
## Module Exports
|
||||
All modules export via `index.ts` barrel files:
|
||||
- `/modules/index.ts` exports: audit, dashboard, departments, user-preferences, integrations, lean, hr
|
||||
- `/modules/lean/index.ts` exports: s3-planning, skill-matrix, morning-meeting
|
||||
- `/modules/hr/index.ts` exports: employees, absences, time-tracking
|
||||
|
||||
## Health Endpoints
|
||||
- Located at `src/health/` (NOT `src/modules/health/`)
|
||||
- `GET /health` - Full check (database, memory, modules status)
|
||||
- `GET /health/liveness` - Kubernetes liveness
|
||||
- `GET /health/readiness` - Database connectivity
|
||||
- `ModulesHealthIndicator` reports core/hr/lean/integrations status
|
||||
|
||||
## Test Infrastructure
|
||||
- **Web (apps/web)**: Vitest 2.x + @testing-library/react + jsdom
|
||||
- Config: `apps/web/vitest.config.ts`
|
||||
- Setup: `apps/web/src/test/setup.ts` (imports @testing-library/jest-dom/vitest)
|
||||
- Scripts: `test` (vitest run), `test:watch` (vitest)
|
||||
- **API (apps/api)**: Jest 29.x + ts-jest + @nestjs/testing
|
||||
- Config: inline in `package.json` under `jest` key
|
||||
- rootDir: `src`, testRegex: `.*\\.spec\\.ts$`
|
||||
- Module alias: `@/` -> `<rootDir>/`
|
||||
|
||||
## Phase 3: Integrations
|
||||
Location: `src/modules/integrations/`
|
||||
Sub-modules: credentials/, sync/, status/, jobs/
|
||||
Types: PLENTYONE, ZULIP, TODOIST, FREESCOUT, NEXTCLOUD, ECODMS, GEMBADOCS
|
||||
|
||||
## Phase 4: LEAN
|
||||
Location: `src/modules/lean/`
|
||||
- `s3-planning/` - 3S/5S audit planning (permissions: S3_VIEW/CREATE/UPDATE/DELETE/MANAGE)
|
||||
- `skill-matrix/` - Skills and employee skill entries
|
||||
- `morning-meeting/` - SQCDM meetings, topics, actions (permissions: MEETING_VIEW/CREATE/UPDATE/DELETE)
|
||||
|
||||
## Phase 5: HR
|
||||
Location: `src/modules/hr/`
|
||||
- `employees/` - CRUD, org chart, **encrypted salary + bankAccount**
|
||||
- `absences/` - Approval workflow (PENDING->APPROVED/REJECTED/CANCELLED)
|
||||
- `time-tracking/` - Clock in/out, German ArbZG break compliance
|
||||
|
||||
### Absences Workflow
|
||||
- Auto-approved: SICK, SICK_CHILD
|
||||
- Vacation balance: 30 days/year, pro-rata by entry date
|
||||
|
||||
### Time Tracking
|
||||
- German labor law breaks: >6h=30min, >9h=45min
|
||||
- Monthly summary with overtime calculation
|
||||
|
||||
## Scripts (from root)
|
||||
```bash
|
||||
pnpm run dev:api # Development server
|
||||
pnpm run db:migrate # Run migrations
|
||||
pnpm run db:generate # Generate Prisma client
|
||||
pnpm run db:seed # Seed default data
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
- Use `CommonModule` (@Global) for shared services like EncryptionService
|
||||
- DTOs with class-validator for input validation
|
||||
- Swagger decorators for API documentation
|
||||
- `@AuditLog('Entity', 'ACTION')` for audit trail
|
||||
|
||||
See detailed docs in `agent-memory/backend-specialist/` for:
|
||||
- [integrations.md](./integrations.md) - Integration details
|
||||
- [hr-module.md](./hr-module.md) - HR module specifics
|
||||
- [testing.md](./testing.md) - Test infrastructure details
|
||||
@@ -1,65 +0,0 @@
|
||||
# tOS Test Infrastructure
|
||||
|
||||
## Frontend (apps/web) - Vitest
|
||||
|
||||
### Configuration
|
||||
- `vitest.config.ts` at project root of apps/web
|
||||
- Uses `@vitejs/plugin-react` for JSX transform
|
||||
- jsdom environment for DOM testing
|
||||
- `globals: true` so `describe/it/expect` are global
|
||||
- Path alias `@` -> `./src` matching tsconfig
|
||||
- CSS disabled in tests (`css: false`)
|
||||
- Setup file: `src/test/setup.ts` imports `@testing-library/jest-dom/vitest`
|
||||
|
||||
### Dependencies (devDependencies)
|
||||
- vitest ^2.1.8
|
||||
- @testing-library/react ^16.1.0
|
||||
- @testing-library/jest-dom ^6.6.3
|
||||
- @testing-library/user-event ^14.5.2
|
||||
- @vitejs/plugin-react ^4.3.4
|
||||
- jsdom ^25.0.1
|
||||
|
||||
### Test Files
|
||||
- `src/lib/utils.test.ts` - Tests for cn(), getInitials(), capitalize(), truncate(), safeJsonParse(), generateId(), isServer(), isClient()
|
||||
- `src/components/ui/badge.test.tsx` - Badge component rendering with all variants
|
||||
- `src/hooks/use-toast.test.ts` - Tests reducer logic and toast() function
|
||||
|
||||
### Run Commands
|
||||
```bash
|
||||
pnpm --filter web test # vitest run
|
||||
pnpm --filter web test:watch # vitest (watch mode)
|
||||
```
|
||||
|
||||
## Backend (apps/api) - Jest
|
||||
|
||||
### Configuration
|
||||
- Inline in `package.json` under `"jest"` key
|
||||
- ts-jest transform for TypeScript
|
||||
- Node test environment
|
||||
- Module alias: `@/` -> `<rootDir>/` (rootDir = src)
|
||||
- Test regex: `.*\.spec\.ts$`
|
||||
|
||||
### Dependencies (already present)
|
||||
- jest ^29.7.0
|
||||
- ts-jest ^29.1.1
|
||||
- @nestjs/testing ^10.3.0
|
||||
- @types/jest ^29.5.11
|
||||
- supertest ^6.3.3
|
||||
|
||||
### Test Files
|
||||
- `src/health/health.controller.spec.ts` - Health controller (check, liveness, readiness)
|
||||
- `src/common/services/encryption.service.spec.ts` - EncryptionService (encrypt/decrypt, objects, empty strings, generateKey, init)
|
||||
|
||||
### Test Patterns for NestJS
|
||||
- Use `Test.createTestingModule()` for DI setup
|
||||
- Mock all dependencies with `{ provide: X, useValue: mockX }`
|
||||
- Call `module.get<T>(T)` to get the instance under test
|
||||
- For services with `onModuleInit()`, call it manually in `beforeEach`
|
||||
- Use `jest.clearAllMocks()` in `afterEach`
|
||||
|
||||
### Run Commands
|
||||
```bash
|
||||
pnpm --filter api test # jest
|
||||
pnpm --filter api test:watch # jest --watch
|
||||
pnpm --filter api test:cov # jest --coverage
|
||||
```
|
||||
@@ -1,52 +0,0 @@
|
||||
# tOS Infrastructure Memory
|
||||
|
||||
## Docker Stack
|
||||
- **Location**: `/home/mehmed/Entwicklung/githubProjekte/tOS/docker/`
|
||||
- **Compose file**: `docker-compose.yml` (name: tos)
|
||||
- **Services**: PostgreSQL 16, Redis 7, Keycloak 24.0
|
||||
- **Network**: `tos-network` (bridge)
|
||||
- **Volumes**: `tos-postgres-data`, `tos-redis-data`
|
||||
|
||||
## Ports (Default)
|
||||
| Service | Port |
|
||||
|------------|------|
|
||||
| PostgreSQL | 5432 |
|
||||
| Redis | 6379 |
|
||||
| Keycloak | 8080 |
|
||||
| API | 3001 |
|
||||
| Frontend | 3000 |
|
||||
|
||||
## Keycloak Configuration
|
||||
- **Realm**: `tOS`
|
||||
- **Clients**: `tos-frontend` (public), `tos-backend` (confidential)
|
||||
- **Roles Hierarchy**:
|
||||
- admin -> hr-manager, manager, department_head, team-lead, employee
|
||||
- hr-manager -> employee
|
||||
- manager -> department_head, employee
|
||||
- department_head -> team-lead, employee
|
||||
- team-lead -> employee
|
||||
- **Test Users**: admin, manager, depthead, employee, hrmanager, teamlead
|
||||
- **Default passwords**: `<username>123` (temporary)
|
||||
|
||||
## Environment Variables
|
||||
- **Root `.env.example`**: Application config (NextAuth, Keycloak, API keys)
|
||||
- **Docker `.env.example`**: Container config (ports, credentials)
|
||||
- **Critical Production Secrets**:
|
||||
- `ENCRYPTION_KEY` - 32 bytes for credential encryption
|
||||
- `JWT_SECRET` - API token signing
|
||||
- `NEXTAUTH_SECRET` - Session encryption
|
||||
- `KEYCLOAK_BACKEND_CLIENT_SECRET`
|
||||
|
||||
## Package Scripts
|
||||
```bash
|
||||
pnpm docker:up # Start infrastructure
|
||||
pnpm docker:down # Stop infrastructure
|
||||
pnpm docker:logs # View logs
|
||||
pnpm docker:reset # Destroy volumes and restart
|
||||
pnpm dev # Start dev servers
|
||||
```
|
||||
|
||||
## Known Issues / Lessons Learned
|
||||
- Keycloak 24+ (UBI9) has no curl; use bash TCP redirect for health checks
|
||||
- Realm import: file must be at `/opt/keycloak/data/import/` with `--import-realm` flag
|
||||
- Health check `start_period` should be 90s+ for Keycloak (Java startup)
|
||||
@@ -1,325 +0,0 @@
|
||||
# tOS Frontend - Agent Memory
|
||||
|
||||
## Project Overview
|
||||
- **Type**: Enterprise Web Dashboard (Next.js 14+ App Router)
|
||||
- **Location**: `/home/mehmed/Entwicklung/githubProjekte/tOS/apps/web/`
|
||||
- **Language**: Code/comments in English, UI in German (default) via i18n
|
||||
|
||||
## Tech Stack
|
||||
- Next.js 14+ with App Router
|
||||
- TypeScript (strict mode)
|
||||
- Tailwind CSS + shadcn/ui (new-york style)
|
||||
- next-themes for dark/light mode
|
||||
- next-intl for i18n (de default, en supported)
|
||||
- NextAuth with Keycloak provider
|
||||
- Framer Motion for animations
|
||||
- Zustand for client state (sidebar-store, dashboard-store)
|
||||
- TanStack Query for server state
|
||||
- TanStack Table for data tables
|
||||
- Recharts for chart components
|
||||
- dnd-kit for drag and drop
|
||||
- date-fns for date formatting
|
||||
- Lucide React for icons
|
||||
|
||||
## File Structure
|
||||
```
|
||||
apps/web/src/
|
||||
app/[locale]/ # Locale-based routing
|
||||
(auth)/ # Protected routes with sidebar layout
|
||||
dashboard/ # Widget-based dashboard
|
||||
settings/ # profile/, preferences/, security/, notifications/
|
||||
admin/ # users/, departments/, integrations/ - role protected
|
||||
lean/ # 3s-planning/, morning-meeting/, skill-matrix/
|
||||
hr/ # employees/, time-tracking/, absences/
|
||||
integrations/ # Overview + [type]/ dynamic routes
|
||||
login/ # Public login page
|
||||
components/
|
||||
ui/ # shadcn/ui + DataTable, Badge, Tabs, Switch, etc.
|
||||
layout/ # Sidebar (collapsible sub-nav), Header
|
||||
dashboard/ # Widget system (container, grid, registry)
|
||||
widgets/ # clock, welcome, quick-actions, stats, calendar, activity
|
||||
integrations/ # orders, chat, tasks, tickets, files, documents
|
||||
integrations/ # Shared: status-badge, integration-card, connection-test-button
|
||||
charts/ # bar-chart, line-chart, pie-chart, chart-container
|
||||
providers/ # SessionProvider, ThemeProvider, QueryProvider
|
||||
hooks/ # use-toast, use-media-query, use-mounted
|
||||
integrations/ # use-orders, use-messages, use-tasks, etc.
|
||||
lib/ # utils, api, auth, motion variants
|
||||
stores/ # sidebar-store, dashboard-store
|
||||
types/ # User, UserRole, Department, WidgetConfig, integrations.ts
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### 1. Server/Client Component Split
|
||||
- `page.tsx` = Server Component (metadata only)
|
||||
- `*-content.tsx` = Client Component (receives locale prop)
|
||||
|
||||
### 2. Widget System (Phase 2)
|
||||
- Registry pattern: `widget-registry.ts` defines all widget types
|
||||
- Drag & drop: dnd-kit with SortableContext
|
||||
- Persistence: Zustand store with localStorage
|
||||
- Each widget: WidgetContainer wrapper + specific content
|
||||
|
||||
### 3. Role-Based Navigation
|
||||
- NavItem has optional `requiredRoles: UserRole[]`
|
||||
- Sidebar filters items via `filterNavItems(items, userRoles)`
|
||||
- Roles: admin, manager, department_head, employee
|
||||
|
||||
### 4. DataTable Pattern
|
||||
- Generic component: `DataTable<TData, TValue>`
|
||||
- Use `DataTableColumnHeader` for sortable columns
|
||||
- Features: search, pagination, column visibility, row selection
|
||||
|
||||
### 5. Chart Components
|
||||
- Wrap Recharts in `ChartContainer` for consistent styling
|
||||
- Support loading states, empty states
|
||||
- Theme-aware colors via CSS variables
|
||||
|
||||
### 6. Integration Hooks (Phase 3)
|
||||
- TanStack Query with 30s staleTime
|
||||
- Mock data for development, TODO comments for API replacement
|
||||
- Query key factories: `ordersKeys`, `messagesKeys`, etc.
|
||||
- Optimistic updates for complete/toggle actions
|
||||
|
||||
### 7. Integration Widgets
|
||||
- Widget categories: `integrations` added to registry
|
||||
- requiredRoles: ['manager', 'admin'] for sensitive widgets
|
||||
- Each widget uses WidgetContainer + specific hook
|
||||
|
||||
## Sidebar Behavior
|
||||
- Expanded: 240px, Collapsed: 64px
|
||||
- Collapsible sub-navigation with ChevronDown animation
|
||||
- Tooltip shows sub-nav when collapsed
|
||||
|
||||
## i18n Keys
|
||||
- Flat structure with nested objects: `widgets.clock.name`
|
||||
- Use ASCII for German (oe, ae, ue) to avoid encoding issues
|
||||
|
||||
## Dependencies Added (Phase 2)
|
||||
- @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities
|
||||
- @tanstack/react-table
|
||||
- @radix-ui/react-checkbox, collapsible, popover, select, tabs
|
||||
- recharts, date-fns
|
||||
|
||||
## Dependencies Added (Phase 3)
|
||||
- @radix-ui/react-switch (for Switch component)
|
||||
|
||||
## Integration Types (Phase 3+)
|
||||
- `plenty-one`: PlentyONE (e-commerce) - OrdersWidget
|
||||
- `zulip`: ZULIP (chat) - ChatWidget
|
||||
- `todoist`: Todoist (tasks) - TasksWidget
|
||||
- `freescout`: FreeScout (helpdesk) - TicketsWidget
|
||||
- `nextcloud`: Nextcloud (files) - FilesWidget
|
||||
- `ecodms`: ecoDMS (documents) - DocumentsWidget
|
||||
- `gembadocs`: GembaDocs (audits/compliance) - GembaDocsWidget
|
||||
|
||||
## LEAN Module Structure
|
||||
|
||||
### File Organization
|
||||
```
|
||||
apps/web/src/
|
||||
app/[locale]/(auth)/lean/
|
||||
page.tsx # LEAN overview
|
||||
s3-planning/
|
||||
page.tsx # S3 plans overview
|
||||
[departmentId]/page.tsx # Department detail view
|
||||
morning-meeting/
|
||||
page.tsx # Meetings overview + calendar
|
||||
morning-meeting-overview-content.tsx
|
||||
[departmentId]/
|
||||
page.tsx # Department SQCDM board
|
||||
department-meeting-content.tsx
|
||||
skill-matrix/
|
||||
page.tsx # Overview with department cards
|
||||
skill-matrix-overview-content.tsx
|
||||
[departmentId]/
|
||||
page.tsx # Department matrix grid + gap analysis
|
||||
department-skill-matrix-content.tsx
|
||||
components/lean/
|
||||
s3/ # S3 Planning components
|
||||
index.ts # Barrel export
|
||||
s3-status-cell.tsx # Colored week cell
|
||||
s3-status-modal.tsx # Status edit dialog
|
||||
s3-category-card.tsx # Category with week grid
|
||||
s3-progress-chart.tsx # Pie chart stats
|
||||
s3-plan-overview.tsx # Plans list with filters
|
||||
s3-department-view.tsx # Week calendar view
|
||||
morning-meeting/ # Morning Meeting components
|
||||
index.ts # Barrel export
|
||||
meeting-board.tsx # Main board with all 5 SQCDM columns
|
||||
sqcdm-column.tsx # Single S/Q/C/D/M column
|
||||
kpi-card.tsx # KPI with traffic light + trend
|
||||
meeting-timer.tsx # Timer with start/stop, countdown mode
|
||||
action-list.tsx # Action items container with filters
|
||||
action-item.tsx # Single action with status/assignee
|
||||
hooks/lean/
|
||||
index.ts # Barrel export
|
||||
use-s3-plans.ts # Plans CRUD + types
|
||||
use-s3-status.ts # Status updates
|
||||
use-meetings.ts # Meeting CRUD + topics/actions
|
||||
use-meeting-timer.ts # Timer logic (elapsed/countdown)
|
||||
```
|
||||
|
||||
### S3 Status Types & Colors
|
||||
- `NOT_APPLICABLE` (gray): Not started
|
||||
- `YELLOW`: In progress
|
||||
- `GREEN`: Completed
|
||||
- `RED`: Problem/Issue
|
||||
|
||||
### S3 Type Categories
|
||||
- `SEIRI`: Sort
|
||||
- `SEITON`: Set in Order
|
||||
- `SEISO`: Shine
|
||||
|
||||
### API Endpoints (Backend)
|
||||
- `GET /lean/s3/plans` - List with filters (year, month, departmentId)
|
||||
- `GET /lean/s3/plans/:id` - Single plan with categories/statuses
|
||||
- `PUT /lean/s3/status/:id` - Update status entry
|
||||
|
||||
### Skill Matrix Module
|
||||
```
|
||||
components/lean/skill-matrix/
|
||||
index.ts # Barrel export
|
||||
skill-level-badge.tsx # Level badge with color coding (0-4)
|
||||
skill-cell.tsx # Matrix cell with quick-edit popover
|
||||
skill-matrix-grid.tsx # Full grid (employees x skills)
|
||||
skill-gap-chart.tsx # Bar chart for gap analysis
|
||||
|
||||
hooks/lean/
|
||||
use-skills.ts # Skills CRUD + categories
|
||||
use-skill-matrix.ts # Matrix data + gap analysis
|
||||
```
|
||||
|
||||
### Skill Level Colors
|
||||
- `0` (gray): No knowledge
|
||||
- `1` (red): Basics
|
||||
- `2` (yellow): Independent
|
||||
- `3` (green): Expert
|
||||
- `4` (blue): Can train
|
||||
|
||||
### Skill Matrix API Endpoints (Backend)
|
||||
- `GET /lean/skills` - List with filters
|
||||
- `GET /lean/skills/department/:id` - Skills for department
|
||||
- `GET /lean/skill-matrix/:departmentId` - Full matrix
|
||||
- `GET /lean/skill-matrix/gaps/:departmentId` - Gap analysis
|
||||
- `POST /lean/skill-matrix/entries` - Create entry
|
||||
- `PUT /lean/skill-matrix/entries/:id` - Update entry
|
||||
- `POST /lean/skill-matrix/entries/bulk` - Bulk upsert
|
||||
|
||||
## HR Module (Phase 5)
|
||||
|
||||
### File Structure
|
||||
```
|
||||
apps/web/src/
|
||||
app/[locale]/(auth)/hr/
|
||||
page.tsx # HR overview with stats
|
||||
hr-overview-content.tsx # Client component
|
||||
employees/
|
||||
page.tsx # Employee list
|
||||
employees-content.tsx
|
||||
[id]/
|
||||
page.tsx # Employee details
|
||||
employee-detail-content.tsx
|
||||
new/
|
||||
page.tsx # New employee form
|
||||
new-employee-content.tsx
|
||||
org-chart/
|
||||
page.tsx # Organization chart
|
||||
org-chart-content.tsx
|
||||
components/hr/employees/
|
||||
index.ts # Barrel export
|
||||
employee-card.tsx # Quick overview card
|
||||
employee-list.tsx # DataTable with filters
|
||||
employee-form.tsx # Create/edit form
|
||||
org-chart.tsx # Hierarchical tree view
|
||||
hooks/hr/
|
||||
index.ts # Barrel export
|
||||
use-employees.ts # CRUD + types + mock data
|
||||
```
|
||||
|
||||
### Employee Types
|
||||
- `EmploymentStatus`: active, inactive, on_leave, terminated
|
||||
- `ContractType`: full_time, part_time, mini_job, intern, trainee, freelance
|
||||
- Full Employee interface with address, emergency contact
|
||||
|
||||
### Employee Status Colors
|
||||
- `active` (green)
|
||||
- `inactive` (gray)
|
||||
- `on_leave` (yellow)
|
||||
- `terminated` (red)
|
||||
|
||||
### i18n Keys (hr namespace)
|
||||
- `hr.title`, `hr.description`
|
||||
- `hr.employees.*` - List/detail pages
|
||||
- `hr.stats.*` - Dashboard statistics
|
||||
- `hr.employeeStatus.*` - Status translations
|
||||
- `hr.contractType.*` - Contract type translations
|
||||
- `hr.form.*` - Form sections
|
||||
- `hr.tabs.*` - Detail view tabs
|
||||
- `hr.toast.*` - Toast notifications
|
||||
|
||||
### Dependencies Added (Phase 5)
|
||||
- react-day-picker (for Calendar component)
|
||||
- react-hook-form + zod (already present)
|
||||
- @radix-ui/react-progress (for Progress component)
|
||||
|
||||
## HR Time Tracking Module (Phase 5)
|
||||
|
||||
### File Structure
|
||||
```
|
||||
apps/web/src/
|
||||
app/[locale]/(auth)/hr/
|
||||
time-tracking/
|
||||
page.tsx # Time tracking overview
|
||||
time-tracking-content.tsx # Time clock + entries
|
||||
[employeeId]/
|
||||
page.tsx # Employee time account
|
||||
employee-time-account-content.tsx
|
||||
absences/
|
||||
page.tsx # Absences overview
|
||||
absences-content.tsx # Balance + requests
|
||||
calendar/
|
||||
page.tsx # Team calendar view
|
||||
absence-calendar-content.tsx
|
||||
requests/
|
||||
page.tsx # Manager approval view
|
||||
absence-requests-content.tsx
|
||||
components/hr/
|
||||
time-tracking/
|
||||
index.ts # Barrel export
|
||||
time-clock.tsx # Web clock (clock in/out, breaks)
|
||||
time-entry-list.tsx # List of time entries
|
||||
time-entry-form.tsx # Correction request dialog
|
||||
time-summary.tsx # Monthly summary with progress
|
||||
absences/
|
||||
index.ts # Barrel export
|
||||
absence-calendar.tsx # Monthly calendar with absences
|
||||
absence-request-form.tsx # Create absence request dialog
|
||||
absence-card.tsx # Single absence with status
|
||||
absence-approval-list.tsx # Pending requests for managers
|
||||
vacation-balance.tsx # Vacation quota display
|
||||
hooks/hr/
|
||||
use-time-tracking.ts # Clock in/out, entries, summary
|
||||
use-absences.ts # Requests, balance, calendar
|
||||
types/hr.ts # TimeEntry, Absence, VacationBalance types
|
||||
```
|
||||
|
||||
### Time Tracking Types
|
||||
- `TimeEntryStatus`: CLOCKED_IN, ON_BREAK, CLOCKED_OUT
|
||||
- `TimeEntryType`: REGULAR, OVERTIME, CORRECTED
|
||||
- `TIME_STATUS_INFO`: Color mapping for status badges
|
||||
|
||||
### Absence Types
|
||||
- `AbsenceType`: VACATION, SICK, HOME_OFFICE, SPECIAL_LEAVE, UNPAID_LEAVE, TRAINING
|
||||
- `AbsenceRequestStatus`: PENDING, APPROVED, REJECTED, CANCELLED
|
||||
- `ABSENCE_TYPE_INFO`: Color + icon mapping
|
||||
- `ABSENCE_STATUS_INFO`: Status color mapping
|
||||
|
||||
### i18n Keys (hr namespace)
|
||||
- `hr.timeTracking.*` - Clock, entries, summary
|
||||
- `hr.absences.*` - Balance, requests, calendar, approvals
|
||||
|
||||
## Related Files
|
||||
- [shared-package.md](./shared-package.md) - @tos/shared integration guide & differences
|
||||
- [component-patterns.md](./component-patterns.md) - Code examples
|
||||
@@ -1,204 +0,0 @@
|
||||
# Component Patterns - tOS Frontend
|
||||
|
||||
## Layout Component Pattern
|
||||
```tsx
|
||||
// src/components/layout/sidebar.tsx
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useSidebarStore } from '@/stores/sidebar-store';
|
||||
|
||||
export function Sidebar({ locale }: { locale: string }) {
|
||||
const { isExpanded, toggleSidebar } = useSidebarStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
initial={false}
|
||||
animate={{ width: isExpanded ? 240 : 64 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="fixed left-0 top-0 z-40 h-screen border-r bg-sidebar"
|
||||
>
|
||||
{/* Content */}
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Page with Metadata Pattern
|
||||
```tsx
|
||||
// page.tsx (Server Component)
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { ContentComponent } from './content-component';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('namespace');
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <ContentComponent />;
|
||||
}
|
||||
|
||||
// content-component.tsx (Client Component)
|
||||
'use client';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function ContentComponent() {
|
||||
const t = useTranslations('namespace');
|
||||
return <div>{t('key')}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Zustand Store Pattern
|
||||
```tsx
|
||||
// src/stores/sidebar-store.ts
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface SidebarState {
|
||||
isExpanded: boolean;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export const useSidebarStore = create<SidebarState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isExpanded: true,
|
||||
toggleSidebar: () => set((state) => ({ isExpanded: !state.isExpanded })),
|
||||
}),
|
||||
{
|
||||
name: 'tos-sidebar-state',
|
||||
partialize: (state) => ({ isExpanded: state.isExpanded }),
|
||||
}
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Animation Pattern with Framer Motion
|
||||
```tsx
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
// Staggered list animation
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ staggerChildren: 0.1 }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1, duration: 0.3, ease: 'easeOut' }}
|
||||
>
|
||||
{item.content}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
## shadcn/ui Component with Variants
|
||||
```tsx
|
||||
// src/components/ui/button.tsx
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground',
|
||||
outline: 'border border-input bg-background hover:bg-accent',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
lg: 'h-10 px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Composition Pattern
|
||||
```tsx
|
||||
// src/components/providers/index.tsx
|
||||
'use client';
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<QueryProvider>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</QueryProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## i18n Usage Pattern
|
||||
```tsx
|
||||
// In components
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function Component() {
|
||||
const t = useTranslations('namespace');
|
||||
return <span>{t('key', { param: 'value' })}</span>;
|
||||
}
|
||||
|
||||
// In server components
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export async function ServerComponent() {
|
||||
const t = await getTranslations('namespace');
|
||||
return <span>{t('key')}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Protected Route Layout Pattern
|
||||
```tsx
|
||||
// src/app/[locale]/(auth)/layout.tsx
|
||||
'use client';
|
||||
|
||||
export default function AuthLayout({ children, params: { locale } }) {
|
||||
const { isExpanded } = useSidebarStore();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="hidden lg:block">
|
||||
<Sidebar locale={locale} />
|
||||
</div>
|
||||
<MobileSidebar locale={locale} />
|
||||
|
||||
<div className={cn(
|
||||
'flex min-h-screen flex-col transition-[margin-left] duration-200',
|
||||
'ml-0',
|
||||
isExpanded ? 'lg:ml-[240px]' : 'lg:ml-[64px]'
|
||||
)}>
|
||||
<Header locale={locale} />
|
||||
<main className="flex-1 p-4 md:p-6 lg:p-8">
|
||||
<PageTransition>{children}</PageTransition>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -1,69 +0,0 @@
|
||||
# @tos/shared Package Integration
|
||||
|
||||
## Package Location
|
||||
`packages/shared/` - Built with tsup, outputs CJS + ESM + types to `dist/`
|
||||
|
||||
## Available Exports
|
||||
|
||||
### Utils (`@tos/shared` or `@tos/shared/utils`)
|
||||
- `formatDate(date, options?)` - German locale, 2-digit format (dd.MM.yyyy)
|
||||
- `formatTime(date, options?)` - German locale, HH:mm
|
||||
- `formatDateTime(date)` - Combined date + time
|
||||
- `calculateWorkingDays(start, end)` - Excludes weekends
|
||||
- `minutesToHoursAndMinutes(mins)` - Returns { hours, minutes }
|
||||
- `formatMinutesToTime(mins)` - Returns "HH:MM" string
|
||||
- `getInitials(firstName, lastName)` - Two-arg version, returns "FL"
|
||||
- `getFullName(firstName, lastName)` - Returns "First Last"
|
||||
- `slugify(str)` - URL-safe slug
|
||||
- `isDefined<T>(value)` - Type guard for non-null/undefined
|
||||
- `sleep(ms)` - Promise-based delay
|
||||
|
||||
### Constants (`@tos/shared`)
|
||||
- `DEFAULT_VACATION_DAYS`, `MIN_VACATION_DAYS`
|
||||
- `STANDARD_WORKING_HOURS_PER_WEEK/DAY`, `MAX_WORKING_HOURS_PER_DAY`
|
||||
- `DEFAULT_BREAK_MINUTES`, `REQUIRED_BREAK_MINUTES`, `EXTENDED_BREAK_MINUTES`
|
||||
- `SKILL_LEVELS`, `S3_CATEGORIES`, `SQCDM_CATEGORIES`
|
||||
- `HTTP_STATUS`, `DATE_FORMATS`
|
||||
- `API_VERSION`, `API_PREFIX`, `DEFAULT_PAGE_SIZE`, `MAX_PAGE_SIZE`
|
||||
- `PERMISSIONS`, `ROLE_PERMISSIONS`
|
||||
|
||||
### Types (`@tos/shared` or `@tos/shared/types`)
|
||||
- `User`, `UserRole`, `UserPreferences`, `DashboardWidgetConfig`, `NotificationPreferences`
|
||||
- `CreateUserDto`, `UpdateUserDto`
|
||||
- `Department`, `DepartmentCode`, `DepartmentWithStats`, `CreateDepartmentDto`, `UpdateDepartmentDto`
|
||||
- `Employee`, `ContractType`, `TimeEntry`, `TimeEntryType`, `Absence`, `AbsenceType`, `ApprovalStatus`
|
||||
- `AuthUser`, `TokenPayload`, `LoginRequest`, `AuthResponse`, `Permission`
|
||||
- Skill Matrix: `Skill`, `SkillMatrixEntry`, `SkillMatrix`, `SkillGapAnalysis`, etc.
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
### Web App (apps/web)
|
||||
- `transpilePackages: ['@tos/shared']` in next.config.mjs (transpiles source directly)
|
||||
- `"@tos/shared": "workspace:*"` in package.json
|
||||
- Import directly: `import { getInitials } from '@tos/shared'`
|
||||
|
||||
### API App (apps/api)
|
||||
- `"@tos/shared": "workspace:*"` in package.json
|
||||
- Uses built dist (CJS) - requires `pnpm --filter @tos/shared build` first
|
||||
- Import: `import { calculateWorkingDays, DEFAULT_VACATION_DAYS } from '@tos/shared'`
|
||||
|
||||
## Key Differences: Local vs Shared
|
||||
|
||||
### getInitials
|
||||
- **Shared**: `getInitials(firstName: string, lastName: string)` - two args
|
||||
- **Web local** (`@/lib/utils`): `getInitials(name: string)` - single full name string, splits on space
|
||||
- Both coexist; use shared in components with firstName/lastName, local in header with full name
|
||||
|
||||
### Date Formatting
|
||||
- **Shared**: `formatDate()` uses `{ day: '2-digit', month: '2-digit', year: 'numeric' }`, hardcoded `de-DE`
|
||||
- **Web local**: `formatDate()` uses `{ year: 'numeric', month: 'long', day: 'numeric' }`, accepts locale param
|
||||
- **Web local**: `formatDateShort()` is equivalent to shared `formatDate()` but accepts locale param
|
||||
- NOT interchangeable - different output format
|
||||
|
||||
### sleep
|
||||
- Identical in both - web local now re-exports from `@tos/shared`
|
||||
|
||||
### ApprovalStatus / ContractType
|
||||
- **Shared**: UPPERCASE (`'PENDING'`, `'FULL_TIME'`)
|
||||
- **Web types**: lowercase (`'pending'`, `'full_time'`)
|
||||
- NOT interchangeable - different casing convention
|
||||
@@ -1,169 +0,0 @@
|
||||
# Integration Specialist Memory - tOS Project
|
||||
|
||||
## Project Structure
|
||||
- **API Location**: `/apps/api/src/`
|
||||
- **Integrations Module**: `/apps/api/src/modules/integrations/`
|
||||
- **Connectors**: `/apps/api/src/modules/integrations/connectors/`
|
||||
|
||||
## Phase 3 Connector Implementation (Completed)
|
||||
|
||||
### Base Infrastructure
|
||||
- `base-connector.ts`: Abstract base class with axios, retry logic, rate limiting
|
||||
- `errors/integration.errors.ts`: Custom error classes (Auth, Connection, RateLimit, API, Config, Validation)
|
||||
|
||||
### Implemented Connectors
|
||||
|
||||
#### PlentyONE (`/connectors/plentyone/`)
|
||||
- **Auth**: OAuth2 Client Credentials flow
|
||||
- **Token Management**: Auto-refresh with 5-minute buffer
|
||||
- **Endpoints**: Orders, Stock, Statistics/Revenue
|
||||
- **Rate Limiting**: 60s timeout (PlentyONE can be slow)
|
||||
- **API Base**: `{baseUrl}/rest`
|
||||
|
||||
#### ZULIP (`/connectors/zulip/`)
|
||||
- **Auth**: Basic Auth (email + API key)
|
||||
- **Endpoints**: Messages, Streams, Users
|
||||
- **API Base**: `{baseUrl}/api/v1`
|
||||
- **Note**: Form-encoded POST requests for some endpoints
|
||||
|
||||
#### Todoist (`/connectors/todoist/`)
|
||||
- **Auth**: Bearer Token
|
||||
- **Endpoints**: Tasks, Projects, Sections, Labels
|
||||
- **API Base**: `https://api.todoist.com/rest/v2`
|
||||
- **Note**: Uses X-Request-Id header for idempotency
|
||||
|
||||
#### FreeScout (`/connectors/freescout/`)
|
||||
- **Auth**: API Key via `X-FreeScout-API-Key` header
|
||||
- **Endpoints**: Conversations, Mailboxes, Customers, Tags
|
||||
- **API Base**: `{baseUrl}/api`
|
||||
- **API Docs**: https://github.com/freescout-helpdesk/freescout/wiki/API
|
||||
|
||||
#### Nextcloud (`/connectors/nextcloud/`)
|
||||
- **Auth**: Basic Auth (username + app-password)
|
||||
- **Endpoints**: WebDAV Files, OCS Share API, CalDAV Calendar (basic)
|
||||
- **API Bases**: `{baseUrl}/remote.php/dav/files/{user}` (WebDAV), `{baseUrl}/ocs/v2.php` (OCS)
|
||||
- **Note**: WebDAV uses PROPFIND/MKCOL/MOVE/COPY; OCS needs `OCS-APIRequest: true` header
|
||||
|
||||
#### ecoDMS (`/connectors/ecodms/`)
|
||||
- **Auth**: Session-based (login returns session ID, use `X-Session-Id` header)
|
||||
- **Endpoints**: Documents, Folders, Classifications, Search
|
||||
- **API Base**: `{baseUrl}/api/v1`
|
||||
- **Note**: Session auto-refresh before expiry; supports file upload with FormData
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# PlentyONE
|
||||
PLENTYONE_BASE_URL=
|
||||
PLENTYONE_CLIENT_ID=
|
||||
PLENTYONE_CLIENT_SECRET=
|
||||
|
||||
# ZULIP
|
||||
ZULIP_BASE_URL=
|
||||
ZULIP_EMAIL=
|
||||
ZULIP_API_KEY=
|
||||
|
||||
# Todoist
|
||||
TODOIST_API_TOKEN=
|
||||
|
||||
# FreeScout
|
||||
FREESCOUT_API_URL=
|
||||
FREESCOUT_API_KEY=
|
||||
|
||||
# Nextcloud
|
||||
NEXTCLOUD_URL=
|
||||
NEXTCLOUD_USERNAME=
|
||||
NEXTCLOUD_PASSWORD=
|
||||
|
||||
# ecoDMS
|
||||
ECODMS_API_URL=
|
||||
ECODMS_USERNAME=
|
||||
ECODMS_PASSWORD=
|
||||
ECODMS_API_VERSION=v1
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Retry Logic
|
||||
- Exponential backoff with jitter (0.5-1.5x multiplier)
|
||||
- Default: 3 retries, 1s initial delay, 30s max delay
|
||||
- Retry on: 429, 500, 502, 503, 504
|
||||
|
||||
### Error Handling
|
||||
- `IntegrationConnectionError`: Network issues (retryable)
|
||||
- `IntegrationAuthError`: 401 responses (not retryable)
|
||||
- `IntegrationRateLimitError`: 429 responses (retryable after delay)
|
||||
- `IntegrationApiError`: Other API errors (retryable if 5xx)
|
||||
|
||||
### Module Structure
|
||||
Each connector follows pattern:
|
||||
1. `{name}.types.ts` - TypeScript interfaces
|
||||
2. `{name}.connector.ts` - Low-level API client
|
||||
3. `{name}.service.ts` - Business logic layer
|
||||
4. `{name}.controller.ts` - HTTP endpoints
|
||||
5. `{name}.module.ts` - NestJS module
|
||||
6. `dto/*.dto.ts` - Request/Response DTOs
|
||||
|
||||
## API Endpoints
|
||||
|
||||
```
|
||||
GET /integrations - Overview all integrations
|
||||
GET /integrations/:type/status - Integration status
|
||||
GET /integrations/:type/health - Health check
|
||||
|
||||
POST /integrations/plentyone/test
|
||||
GET /integrations/plentyone/orders
|
||||
GET /integrations/plentyone/orders/:id
|
||||
GET /integrations/plentyone/stock
|
||||
GET /integrations/plentyone/stats
|
||||
|
||||
POST /integrations/zulip/test
|
||||
GET /integrations/zulip/messages
|
||||
POST /integrations/zulip/messages
|
||||
GET /integrations/zulip/streams
|
||||
GET /integrations/zulip/users
|
||||
|
||||
POST /integrations/todoist/test
|
||||
GET /integrations/todoist/tasks
|
||||
POST /integrations/todoist/tasks
|
||||
PUT /integrations/todoist/tasks/:id
|
||||
DELETE /integrations/todoist/tasks/:id
|
||||
GET /integrations/todoist/projects
|
||||
|
||||
# FreeScout
|
||||
POST /integrations/freescout/test
|
||||
GET /integrations/freescout/conversations
|
||||
GET /integrations/freescout/conversations/:id
|
||||
POST /integrations/freescout/conversations
|
||||
POST /integrations/freescout/conversations/:id/reply
|
||||
GET /integrations/freescout/mailboxes
|
||||
GET /integrations/freescout/customers
|
||||
GET /integrations/freescout/tags
|
||||
|
||||
# Nextcloud
|
||||
POST /integrations/nextcloud/test
|
||||
GET /integrations/nextcloud/files
|
||||
GET /integrations/nextcloud/files/*path
|
||||
POST /integrations/nextcloud/files/upload
|
||||
DELETE /integrations/nextcloud/files/*path
|
||||
GET /integrations/nextcloud/calendar/events
|
||||
|
||||
# ecoDMS
|
||||
POST /integrations/ecodms/test
|
||||
GET /integrations/ecodms/documents
|
||||
GET /integrations/ecodms/documents/:id
|
||||
POST /integrations/ecodms/documents/search
|
||||
GET /integrations/ecodms/documents/:id/download
|
||||
GET /integrations/ecodms/folders
|
||||
GET /integrations/ecodms/classifications
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
- `axios`: ^1.6.0 (HTTP client)
|
||||
- axios types included in axios package
|
||||
|
||||
## Notes
|
||||
- All connectors check `isConfigured()` before operations
|
||||
- `getMissingConfig()` returns list of missing env vars
|
||||
- Logging in development mode by default
|
||||
- All API responses transformed to DTOs
|
||||
@@ -1,123 +0,0 @@
|
||||
# tOS Project Code Review Memory
|
||||
|
||||
## Project Overview
|
||||
- **Type:** Enterprise Web Operating System with HR, LEAN modules
|
||||
- **Stack:** Next.js 14 + NestJS 10 + Prisma + PostgreSQL + Keycloak
|
||||
- **Structure:** pnpm monorepo with turbo (apps/web, apps/api, packages/shared)
|
||||
|
||||
## Code Standards (from PLAN.md)
|
||||
| Area | Language | Convention |
|
||||
|------|----------|------------|
|
||||
| Code & Comments | English | Required |
|
||||
| UI Text | German (via i18n) | next-intl |
|
||||
| Variables | camelCase | `employeeId` |
|
||||
| Components | PascalCase | `DashboardWidget` |
|
||||
| Files | kebab-case | `user-service.ts` |
|
||||
| Constants | UPPER_SNAKE_CASE | `MAX_VACATION_DAYS` |
|
||||
|
||||
## Critical Patterns Found
|
||||
|
||||
### Authentication
|
||||
- Global `JwtAuthGuard` with `@Public()` opt-out decorator
|
||||
- Global `RolesGuard` with `@Roles()` decorator
|
||||
- Keycloak integration via NextAuth (frontend) and passport-jwt (backend)
|
||||
- **IMPORTANT:** Always verify JWT signatures, never just decode
|
||||
- Guards registered in correct order: JWT -> Roles -> Permissions (app.module.ts L74-88)
|
||||
- JWT strategy validates user exists and is active on every request
|
||||
|
||||
### Frontend Architecture
|
||||
- **Server/Client split:** Server page.tsx -> Client *-content.tsx
|
||||
- **State:** Zustand (sidebar, dashboard), TanStack Query (server state)
|
||||
- **i18n:** next-intl with de.json/en.json, default locale = "de"
|
||||
- **Dashboard:** dnd-kit widget grid, widget registry pattern
|
||||
- **Styling:** Tailwind + shadcn/ui, HSL CSS vars, dark mode via class
|
||||
|
||||
### Integration Architecture (Phase 3 - Disabled)
|
||||
- **Location:** `apps/api/integrations.backup/`
|
||||
- Only PlentyONE/Zulip/Todoist extend BaseConnector
|
||||
- FreeScout/Nextcloud/ecoDMS/GembaDocs have independent implementations
|
||||
|
||||
### Keycloak Client ID Mismatch (CRITICAL - RECURRING)
|
||||
- Realm: `tos-frontend` / `tos-backend`
|
||||
- apps/api/.env: `tos-api` | apps/web/.env: `tos-nextauth` (MISMATCH)
|
||||
|
||||
## Key Issues to Watch
|
||||
1. **params API:** Mixed sync/async patterns across pages
|
||||
2. **Hardcoded German:** dashboard-content.tsx, widget-grid.tsx bypass i18n
|
||||
3. **Mock Data:** HR hooks (employees, time-tracking, absences) use mock data
|
||||
4. **Type Conflicts:** types/index.ts vs types/hr.ts have conflicting exports
|
||||
5. **Auth Layout:** `(auth)/layout.tsx` is `'use client'` -- blocks SSR
|
||||
6. **Error pages:** Link to `/dashboard` without locale prefix
|
||||
7. **ENCRYPTION_KEY:** `optional()` in validation -- must be required
|
||||
|
||||
### Backend-Specific Issues (from Phase 7 Review)
|
||||
8. **Morning Meeting Permissions:** ALL endpoints use DASHBOARD_VIEW -- any user can CRUD meetings
|
||||
9. **Private method access:** time-tracking.controller.ts L237-239 uses bracket notation `this.service['privateMethod']`
|
||||
10. **Prisma cleanDatabase():** Uses Promise.all ignoring FK constraints (prisma.service.ts L48-61)
|
||||
11. **Roles guard leaks info:** Error messages reveal required role names (roles.guard.ts L31-33)
|
||||
12. **enableImplicitConversion:** In ValidationPipe (main.ts L44) -- can cause type coercion bugs
|
||||
13. **Break tracking via string parsing:** time-tracking uses note field for break detection (L216-219, L257-258)
|
||||
14. **Audit interceptor:** oldData always undefined (audit.interceptor.ts L60), salary not sanitized (L131)
|
||||
15. **Unregistered interceptors:** LoggingInterceptor and TimeoutInterceptor defined but never registered
|
||||
16. **N+1 queries:** skill-entries.service.ts analyzeGaps (L515-523), s3-planning findAll (L173-178)
|
||||
17. **bulkUpsert not transactional:** skill-entries.service.ts L429-476 -- partial failures possible
|
||||
18. **No backdating limit:** Manual time entries have no restriction on how far back entries can be created
|
||||
19. **Holiday calculation missing:** absences calculateWorkingDays does not account for public holidays
|
||||
20. **Vacation carry-over TODO:** BUrlG compliance gap (absences.service.ts L979)
|
||||
|
||||
## File Locations
|
||||
| Purpose | Path |
|
||||
|---------|------|
|
||||
| Prisma Schema | `apps/api/prisma/schema.prisma` |
|
||||
| Frontend Types | `apps/web/src/types/` |
|
||||
| HR Hooks | `apps/web/src/hooks/hr/` |
|
||||
| LEAN Hooks | `apps/web/src/hooks/lean/` |
|
||||
| Dashboard | `apps/web/src/components/dashboard/` |
|
||||
| i18n Messages | `apps/web/messages/` |
|
||||
| Integration Backup | `apps/api/integrations.backup/` |
|
||||
| Auth Guards | `apps/api/src/auth/guards/` |
|
||||
| Auth Permissions | `apps/api/src/auth/permissions/` |
|
||||
| Encryption Service | `apps/api/src/common/services/encryption.service.ts` |
|
||||
| HR Employees | `apps/api/src/modules/hr/employees/` |
|
||||
| HR Time Tracking | `apps/api/src/modules/hr/time-tracking/` |
|
||||
| HR Absences | `apps/api/src/modules/hr/absences/` |
|
||||
| LEAN Skill Matrix | `apps/api/src/modules/lean/skill-matrix/` |
|
||||
| LEAN Morning Meeting | `apps/api/src/modules/lean/morning-meeting/` |
|
||||
| LEAN S3 Planning | `apps/api/src/modules/lean/s3-planning/` |
|
||||
| Audit Module | `apps/api/src/modules/audit/` |
|
||||
| Config Validation | `apps/api/src/config/config.validation.ts` |
|
||||
|
||||
## Review History
|
||||
|
||||
### Phase 7 Full Backend Review (2026-02-06)
|
||||
- **Overall: 7.8/10** | 3 Critical, 14 Important issues
|
||||
- Scores: Auth 8 | Prisma 7 | HR/Emp 8 | HR/Time 7 | HR/Abs 8
|
||||
- LEAN/Skill 8 | LEAN/Morning 7 | LEAN/S3 8 | Dashboard 8
|
||||
- Common 8 | Users 8 | Audit 7 | app.module 9
|
||||
- See: `phase7-backend-review.md`
|
||||
|
||||
### Phase 6 Frontend Review (2026-02-06)
|
||||
- 5 Critical, 9 Important, detailed 10-area review
|
||||
- See: `phase6-frontend-review.md`
|
||||
|
||||
### Infrastructure + Integration Review (2026-02-06)
|
||||
- Docker 7/10 | Keycloak 7/10 | Env 4/10 | Integration 6/10
|
||||
- See: `infra-integration-review.md`
|
||||
|
||||
### Phase 5 Review (2026-02-05)
|
||||
- HR modules: Employees, Time Tracking, Absences
|
||||
|
||||
### Phase 3 Review (2026-02-05)
|
||||
- Integration connectors reviewed; module now disabled
|
||||
|
||||
### Phase 1 Review (2024)
|
||||
- JWT validation, type sync, CORS, Keycloak fixes applied
|
||||
|
||||
## Backend Architecture Notes
|
||||
- Global guards chain: JwtAuthGuard -> RolesGuard -> PermissionsGuard
|
||||
- Response envelope via TransformInterceptor: `{success, data, timestamp}`
|
||||
- Global HttpExceptionFilter catches all exceptions, no internal leaks
|
||||
- AES-256-GCM encryption for salary + bank accounts (fixed salt issue noted)
|
||||
- Audit via decorator `@AuditLog()` + global AuditInterceptor
|
||||
- Permissions enum uses entity:action format (e.g., `employees:read`)
|
||||
- DEFAULT_ROLE_PERMISSIONS maps roles to permission arrays
|
||||
@@ -1,71 +0,0 @@
|
||||
# Phase 7 - Full Backend Code Review (2026-02-06)
|
||||
|
||||
## Overall Score: 7.8/10
|
||||
|
||||
## Module Scores
|
||||
|
||||
| Module | Score | Key Issue |
|
||||
|--------|-------|-----------|
|
||||
| Auth (guards, strategy, decorators) | 8/10 | Role names leaked in error messages |
|
||||
| Prisma (service, module) | 7/10 | cleanDatabase() ignores FK constraints |
|
||||
| HR/Employees | 8/10 | `as any` type casts for encrypted data |
|
||||
| HR/Time Tracking | 7/10 | Break tracking via string parsing, private method access |
|
||||
| HR/Absences | 8/10 | Missing holiday calc, vacation carry-over TODO |
|
||||
| LEAN/Skill Matrix | 8/10 | N+1 in analyzeGaps, non-transactional bulkUpsert |
|
||||
| LEAN/Morning Meeting | 7/10 | ALL endpoints use DASHBOARD_VIEW permission |
|
||||
| LEAN/S3 Planning | 8/10 | File upload MIME not validated, N+1 in findAll |
|
||||
| Dashboard | 8/10 | Hardcoded role strings |
|
||||
| Common (filters, interceptors) | 8/10 | Logging/Timeout interceptors never registered |
|
||||
| Users | 8/10 | GET /users has no permission restriction |
|
||||
| Audit | 7/10 | oldData always undefined, salary not sanitized |
|
||||
| app.module + main.ts | 9/10 | enableImplicitConversion risk |
|
||||
|
||||
## Critical Issues (3)
|
||||
|
||||
### 1. Morning Meeting Permission Escalation
|
||||
- **File:** `apps/api/src/modules/lean/morning-meeting/morning-meeting.controller.ts`
|
||||
- **Lines:** 47, 59, 66, 79, 99, 120, 149, 159, 170, 183, 193, 211, 221, 234, 249, 259, 270
|
||||
- **Issue:** ALL endpoints (including create, update, delete) use `Permission.DASHBOARD_VIEW`
|
||||
- **Impact:** Any authenticated user with dashboard access can create/modify/delete morning meetings
|
||||
|
||||
### 2. Private Method Access via Bracket Notation
|
||||
- **File:** `apps/api/src/modules/hr/time-tracking/time-tracking.controller.ts`
|
||||
- **Lines:** 237-239
|
||||
- **Issue:** `this.timeTrackingService['getEmployeeByUserId'](user.sub)` accesses private method
|
||||
- **Impact:** Circumvents TypeScript access modifiers, fragile to refactoring
|
||||
|
||||
### 3. Prisma cleanDatabase() with Promise.all
|
||||
- **File:** `apps/api/src/prisma/prisma.service.ts`
|
||||
- **Lines:** 48-61
|
||||
- **Issue:** Deletes all tables in parallel ignoring foreign key constraints
|
||||
- **Impact:** Can fail in production/staging if FK constraints exist; should use sequential deletion or raw SQL truncate cascade
|
||||
|
||||
## Important Issues (14)
|
||||
|
||||
1. **Roles guard leaks role names** - roles.guard.ts L31-33
|
||||
2. **Permissions guard leaks permission names** - permissions.guard.ts
|
||||
3. **enableImplicitConversion in ValidationPipe** - main.ts L44
|
||||
4. **Break tracking via note string parsing** - time-tracking.service.ts L216-219, L257-258
|
||||
5. **No backdating limit** for manual time entries
|
||||
6. **Holiday calculation missing** in absences calculateWorkingDays
|
||||
7. **Vacation carry-over not implemented** - absences.service.ts L979 (BUrlG)
|
||||
8. **Event queue in-memory only** - absences.service.ts L1414
|
||||
9. **N+1 query in analyzeGaps** - skill-entries.service.ts L515-523
|
||||
10. **bulkUpsert not transactional** - skill-entries.service.ts L429-476
|
||||
11. **File upload MIME not validated** - s3-planning.controller.ts
|
||||
12. **LoggingInterceptor + TimeoutInterceptor never registered**
|
||||
13. **GET /users no permission check** - users.controller.ts L88-97
|
||||
14. **Audit oldData always undefined** - audit.interceptor.ts L60
|
||||
|
||||
## Positive Observations
|
||||
|
||||
- JWT verification (not just decode) with user existence check on every request
|
||||
- AES-256-GCM encryption for sensitive employee data (salary, bank account)
|
||||
- Consistent error response format via global HttpExceptionFilter
|
||||
- Response envelope pattern via TransformInterceptor
|
||||
- ArbZG break rules correctly implemented in time tracking
|
||||
- Comprehensive absence workflow with approval chain
|
||||
- Global ValidationPipe with whitelist + forbidNonWhitelisted
|
||||
- Proper soft delete patterns for employees
|
||||
- Well-structured module hierarchy (HrModule -> sub-modules)
|
||||
- Swagger/OpenAPI documentation on all endpoints
|
||||
53
.dockerignore
Normal file
53
.dockerignore
Normal file
@@ -0,0 +1,53 @@
|
||||
# =============================================================================
|
||||
# tOS Docker Build Context Ignore
|
||||
# =============================================================================
|
||||
# Diese Datei verhindert, dass unnoetige Dateien in den Docker Build Context
|
||||
# kopiert werden. Das beschleunigt den Build und reduziert die Image-Groesse.
|
||||
# =============================================================================
|
||||
|
||||
# Dependencies (werden im Container neu installiert)
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# Build-Artefakte
|
||||
.next
|
||||
**/dist
|
||||
.turbo
|
||||
|
||||
# Versionskontrolle
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Environment-Dateien (Secrets gehoeren nicht ins Image!)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.development
|
||||
.env.production
|
||||
|
||||
# Docker-Daten (vermeidet rekursives Kopieren)
|
||||
docker/data
|
||||
|
||||
# Test & Coverage
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Temporaere Dateien
|
||||
tmp
|
||||
temp
|
||||
.cache
|
||||
|
||||
# IDE-Konfiguration
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS-Dateien
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,6 +17,9 @@ build/
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# Claude Code
|
||||
.claude/agent-memory/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
86
CLAUDE.md
Normal file
86
CLAUDE.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# tOS – Claude Code Guide
|
||||
|
||||
## Projekt
|
||||
Enterprise Web Operating System: Next.js 14 + NestJS 10 + Prisma + PostgreSQL 16 + Keycloak 24.
|
||||
pnpm-Monorepo mit Turbo (`apps/web`, `apps/api`, `packages/shared`).
|
||||
|
||||
## Dev-Befehle
|
||||
|
||||
```bash
|
||||
# Infrastruktur starten (PostgreSQL, Redis, Keycloak)
|
||||
cd docker && docker compose up -d
|
||||
|
||||
# Apps starten
|
||||
pnpm dev # alle Apps
|
||||
pnpm dev:api # nur Backend
|
||||
pnpm dev:web # nur Frontend
|
||||
|
||||
# Datenbank
|
||||
pnpm db:push # Schema anwenden (dev)
|
||||
pnpm db:seed # Default-Daten (Rollen, SystemSettings)
|
||||
|
||||
# TypeScript-Check
|
||||
pnpm --filter @tos/api exec tsc --noEmit
|
||||
pnpm --filter @tos/web exec tsc --noEmit
|
||||
```
|
||||
|
||||
## Ports
|
||||
| Service | Port |
|
||||
|------------|------|
|
||||
| Frontend | 3000 |
|
||||
| Backend | 3001 |
|
||||
| Keycloak | 8080 |
|
||||
| PostgreSQL | 5432 |
|
||||
| Redis | 6379 |
|
||||
|
||||
## Datenbanken
|
||||
- `tos_db` – Keycloak (wird automatisch erstellt)
|
||||
- `tos_app` – Anwendung (wird via `docker/postgres/init.sql` automatisch erstellt)
|
||||
- Beide in derselben PostgreSQL-Instanz
|
||||
|
||||
## Keycloak (Dev)
|
||||
- Admin-UI: http://localhost:8080 → admin / admin123
|
||||
- Realm: `tOS`
|
||||
- Frontend-Client: `tos-nextauth` (NextAuth)
|
||||
- Backend-Client: `tos-backend` (NestJS)
|
||||
|
||||
## Architektur
|
||||
|
||||
**Auth:**
|
||||
- Keycloak SSO → NextAuth (Frontend-Session) + passport-jwt RS256 via JWKS (Backend)
|
||||
- JWT wird über Keycloak JWKS-Endpoint validiert – KEIN symmetrischer JWT_SECRET
|
||||
- Global Guard Chain: JWT → Roles → Permissions, `@Public()` für opt-out
|
||||
|
||||
**API-Kommunikation:**
|
||||
- Backend wickelt alle Responses in `{success, data, timestamp}` (TransformInterceptor)
|
||||
- Frontend-`api.ts` unwrappt diesen Envelope automatisch
|
||||
- `NEXT_PUBLIC_API_URL` enthält bereits `/api/v1` (z.B. `http://localhost:3001/api/v1`)
|
||||
|
||||
**Konfiguration:**
|
||||
- Integration-Credentials und SystemSettings liegen in der DB, nicht in `.env`
|
||||
- `.env` enthält nur Bootstrap-Variablen (DB-URL, Secrets, Keycloak-Koordinaten)
|
||||
|
||||
## Code-Konventionen
|
||||
- **Route-Pattern:** `page.tsx` (Server Component) importiert `*-content.tsx` (Client Component)
|
||||
- **Routen:** `[locale]/(auth)/...` = geschützt, `[locale]/(setup)/...` = public, `/login` = public
|
||||
- **State:** Zustand für UI-State, TanStack Query für Server-State
|
||||
- **i18n:** ASCII-Umlaute in JSON-Keys (`oe`, `ae`, `ue` statt `ö`, `ä`, `ü`)
|
||||
- **Installer-API-Calls:** direktes `fetch()` mit `x-setup-token` Header (kein Bearer-Token)
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
# Lokaler Full-Stack-Test (identisch zu Prod, vor Commit)
|
||||
cd docker && docker compose -f docker-compose.local.yml up --build
|
||||
# → http://localhost:3000/setup?token=local-setup-token-for-testing
|
||||
|
||||
# Produktions-Installation (interaktiv)
|
||||
./install.sh
|
||||
# → fragt Domain + SSL-Modus, generiert Secrets, startet docker-compose.prod.yml
|
||||
# → SSL via Let's Encrypt (Caddy, --profile ssl) oder externer Reverse Proxy
|
||||
```
|
||||
|
||||
## Bekannte Issues / TODOs
|
||||
- HR-Hooks nutzen noch Mock-Daten (nicht an Backend angebunden)
|
||||
- Morning Meeting Endpoints nutzen `DASHBOARD_VIEW` Permission (Security-Gap)
|
||||
- `apps/api/integrations.backup/` – deaktivierte Integrations-Module (via .gitignore ignoriert)
|
||||
67
apps/api/Dockerfile
Normal file
67
apps/api/Dockerfile
Normal file
@@ -0,0 +1,67 @@
|
||||
# =============================================================================
|
||||
# tOS API - Multi-Stage Docker Build
|
||||
# =============================================================================
|
||||
# Optimiert fuer pnpm Monorepo mit Prisma ORM
|
||||
#
|
||||
# Build: docker build -f apps/api/Dockerfile -t tos-api .
|
||||
# Run: docker run -p 3001:3001 --env-file .env tos-api
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: Base - Node.js mit pnpm
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: Builder - Dependencies installieren und kompilieren
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Kopiere Workspace-Konfiguration (fuer pnpm Monorepo-Aufloesung)
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
|
||||
# Kopiere Shared Package (wird von der API als Dependency referenziert)
|
||||
COPY packages/ ./packages/
|
||||
|
||||
# Kopiere API-Quellcode
|
||||
COPY apps/api/ ./apps/api/
|
||||
|
||||
# Installiere alle Dependencies (frozen-lockfile fuer reproduzierbare Builds)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Baue zuerst das Shared Package (Dependency der API)
|
||||
RUN pnpm --filter @tos/shared build
|
||||
|
||||
# Generiere Prisma Client (benoetigt fuer den Build)
|
||||
RUN pnpm --filter @tos/api exec prisma generate
|
||||
|
||||
# Baue die API
|
||||
RUN pnpm --filter @tos/api build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3: Runner - Schlankes Production Image
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Kopiere Build-Artefakte
|
||||
COPY --from=builder /app/apps/api/dist ./dist
|
||||
COPY --from=builder /app/apps/api/prisma ./prisma
|
||||
COPY --from=builder /app/apps/api/package.json ./package.json
|
||||
|
||||
# Kopiere node_modules (API-spezifisch + hoisted)
|
||||
COPY --from=builder /app/apps/api/node_modules ./node_modules
|
||||
# Kopiere Prisma Client (plattform-spezifische Binaries)
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
# Sicherheit: Nicht als root ausfuehren
|
||||
USER node
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
# Beim Start: Zuerst Datenbankmigrationen anwenden, dann API starten
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]
|
||||
@@ -26,6 +26,7 @@
|
||||
"db:seed": "prisma db seed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
|
||||
@@ -31,6 +31,9 @@ import { HrModule } from './modules/hr/hr.module';
|
||||
// Phase 6 modules - Integrations
|
||||
import { IntegrationsModule } from './modules/integrations/integrations.module';
|
||||
|
||||
// Setup module - initial system configuration wizard
|
||||
import { SetupModule } from './modules/setup/setup.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Configuration
|
||||
@@ -75,6 +78,9 @@ import { IntegrationsModule } from './modules/integrations/integrations.module';
|
||||
|
||||
// Phase 6 modules - Integrations
|
||||
IntegrationsModule,
|
||||
|
||||
// Setup wizard - initial system configuration
|
||||
SetupModule,
|
||||
],
|
||||
providers: [
|
||||
// Global JWT Guard - routes are protected by default
|
||||
|
||||
@@ -27,6 +27,12 @@ export const configValidationSchema = Joi.object({
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
|
||||
// Keycloak Admin (for setup wizard)
|
||||
KEYCLOAK_ADMIN_PASSWORD: Joi.string().optional(),
|
||||
|
||||
// Setup token (initial system setup)
|
||||
SETUP_TOKEN: Joi.string().optional(),
|
||||
|
||||
// Redis (optional - for BullMQ in production)
|
||||
REDIS_HOST: Joi.string().optional(),
|
||||
REDIS_PORT: Joi.number().optional(),
|
||||
|
||||
@@ -10,6 +10,9 @@ import { Public } from '../auth/decorators/public.decorator';
|
||||
import { PrismaHealthIndicator } from './prisma-health.indicator';
|
||||
import { ModulesHealthIndicator } from './modules-health.indicator';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { version } = require('../../package.json');
|
||||
|
||||
@ApiTags('health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@@ -78,6 +81,7 @@ export class HealthController {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', example: 'ok' },
|
||||
version: { type: 'string', example: '0.0.1' },
|
||||
timestamp: { type: 'string', example: '2024-01-15T10:30:00.000Z' },
|
||||
},
|
||||
},
|
||||
@@ -85,6 +89,7 @@ export class HealthController {
|
||||
liveness() {
|
||||
return {
|
||||
status: 'ok',
|
||||
version: version as string,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ async function bootstrap() {
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'x-setup-token'],
|
||||
});
|
||||
|
||||
// API Prefix
|
||||
|
||||
23
apps/api/src/modules/setup/dto/create-admin.dto.ts
Normal file
23
apps/api/src/modules/setup/dto/create-admin.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IsString, IsNotEmpty, IsEmail, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateAdminDto {
|
||||
@ApiProperty({ example: 'Max' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ example: 'Mustermann' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ example: 'admin@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'SecurePass123!', minLength: 8 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
}
|
||||
19
apps/api/src/modules/setup/dto/save-branding.dto.ts
Normal file
19
apps/api/src/modules/setup/dto/save-branding.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsUrl } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class SaveBrandingDto {
|
||||
@ApiProperty({ example: 'tOS' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
appName: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Acme Corp' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
companyName?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com/logo.png' })
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
logoUrl?: string;
|
||||
}
|
||||
67
apps/api/src/modules/setup/setup.controller.ts
Normal file
67
apps/api/src/modules/setup/setup.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Controller, Get, Post, Put, Body, Headers } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiHeader, ApiResponse } from '@nestjs/swagger';
|
||||
import { Public } from '../../auth/decorators/public.decorator';
|
||||
import { SetupService } from './setup.service';
|
||||
import { CreateAdminDto } from './dto/create-admin.dto';
|
||||
import { SaveBrandingDto } from './dto/save-branding.dto';
|
||||
|
||||
@ApiTags('setup')
|
||||
@Public()
|
||||
@Controller('setup')
|
||||
export class SetupController {
|
||||
constructor(private readonly setupService: SetupService) {}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Get current setup status' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Returns whether setup is completed and token is configured',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
completed: { type: 'boolean', example: false },
|
||||
tokenConfigured: { type: 'boolean', example: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
getStatus() {
|
||||
return this.setupService.getStatus();
|
||||
}
|
||||
|
||||
@Post('admin')
|
||||
@ApiOperation({ summary: 'Create initial admin user in Keycloak and local DB' })
|
||||
@ApiHeader({ name: 'x-setup-token', required: true })
|
||||
@ApiResponse({ status: 201, description: 'Admin user created' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid setup token' })
|
||||
@ApiResponse({ status: 403, description: 'Setup already completed' })
|
||||
@ApiResponse({ status: 409, description: 'User already exists in Keycloak' })
|
||||
createAdmin(
|
||||
@Body() dto: CreateAdminDto,
|
||||
@Headers('x-setup-token') token: string,
|
||||
) {
|
||||
return this.setupService.createAdmin(dto, token ?? '');
|
||||
}
|
||||
|
||||
@Put('branding')
|
||||
@ApiOperation({ summary: 'Save branding settings (app name, company, logo)' })
|
||||
@ApiHeader({ name: 'x-setup-token', required: true })
|
||||
@ApiResponse({ status: 200, description: 'Branding settings saved' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid setup token' })
|
||||
@ApiResponse({ status: 403, description: 'Setup already completed' })
|
||||
saveBranding(
|
||||
@Body() dto: SaveBrandingDto,
|
||||
@Headers('x-setup-token') token: string,
|
||||
) {
|
||||
return this.setupService.saveBranding(dto, token ?? '');
|
||||
}
|
||||
|
||||
@Post('complete')
|
||||
@ApiOperation({ summary: 'Mark setup as completed' })
|
||||
@ApiHeader({ name: 'x-setup-token', required: true })
|
||||
@ApiResponse({ status: 201, description: 'Setup marked as completed' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid setup token' })
|
||||
@ApiResponse({ status: 403, description: 'Setup already completed' })
|
||||
completeSetup(@Headers('x-setup-token') token: string) {
|
||||
return this.setupService.completeSetup(token ?? '');
|
||||
}
|
||||
}
|
||||
12
apps/api/src/modules/setup/setup.module.ts
Normal file
12
apps/api/src/modules/setup/setup.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { SetupController } from './setup.controller';
|
||||
import { SetupService } from './setup.service';
|
||||
import { SystemSettingsModule } from '../system-settings/system-settings.module';
|
||||
|
||||
@Module({
|
||||
imports: [SystemSettingsModule, HttpModule],
|
||||
controllers: [SetupController],
|
||||
providers: [SetupService],
|
||||
})
|
||||
export class SetupModule {}
|
||||
206
apps/api/src/modules/setup/setup.service.ts
Normal file
206
apps/api/src/modules/setup/setup.service.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
ConflictException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { SystemSettingsService } from '../system-settings/system-settings.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateAdminDto } from './dto/create-admin.dto';
|
||||
import { SaveBrandingDto } from './dto/save-branding.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SetupService {
|
||||
private readonly logger = new Logger(SetupService.name);
|
||||
|
||||
constructor(
|
||||
private readonly systemSettings: SystemSettingsService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the current setup status.
|
||||
*/
|
||||
async getStatus() {
|
||||
const completed = await this.systemSettings.getValue('setup.completed');
|
||||
const tokenConfigured = await this.systemSettings.getValue('setup.token');
|
||||
|
||||
return {
|
||||
completed: completed === 'true',
|
||||
tokenConfigured: !!tokenConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the initial admin user in Keycloak and the local database.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Validate the setup token
|
||||
* 2. Obtain a Keycloak admin access token via master realm
|
||||
* 3. Create the user in Keycloak with verified email and password
|
||||
* 4. Create/update the user in the local DB with the keycloakId reference
|
||||
* 5. Assign the "admin" role
|
||||
*/
|
||||
async createAdmin(dto: CreateAdminDto, token: string): Promise<void> {
|
||||
await this.validateToken(token);
|
||||
|
||||
const keycloakUrl = this.config.get<string>('KEYCLOAK_URL');
|
||||
const realm = this.config.get<string>('KEYCLOAK_REALM', 'tOS');
|
||||
const adminPassword = this.config.get<string>('KEYCLOAK_ADMIN_PASSWORD');
|
||||
|
||||
// 1. Get admin access token from Keycloak master realm
|
||||
let adminToken: string;
|
||||
try {
|
||||
const tokenRes = await firstValueFrom(
|
||||
this.http.post(
|
||||
`${keycloakUrl}/realms/master/protocol/openid-connect/token`,
|
||||
new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
client_id: 'admin-cli',
|
||||
username: 'admin',
|
||||
password: adminPassword ?? '',
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
||||
),
|
||||
);
|
||||
adminToken = tokenRes.data.access_token;
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to get Keycloak admin token', err);
|
||||
throw new Error('Keycloak admin authentication failed');
|
||||
}
|
||||
|
||||
// 2. Create user in Keycloak
|
||||
let keycloakUserId: string;
|
||||
try {
|
||||
const createRes = await firstValueFrom(
|
||||
this.http.post(
|
||||
`${keycloakUrl}/admin/realms/${realm}/users`,
|
||||
{
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
email: dto.email,
|
||||
username: dto.email,
|
||||
enabled: true,
|
||||
emailVerified: true,
|
||||
credentials: [
|
||||
{ type: 'password', value: dto.password, temporary: false },
|
||||
],
|
||||
},
|
||||
{ headers: { Authorization: `Bearer ${adminToken}` } },
|
||||
),
|
||||
);
|
||||
// Keycloak returns 201 with Location header containing the user ID
|
||||
const location = createRes.headers['location'] as string;
|
||||
keycloakUserId = location.split('/').pop() ?? '';
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 409) {
|
||||
throw new ConflictException(
|
||||
'A user with this email already exists in Keycloak',
|
||||
);
|
||||
}
|
||||
this.logger.error('Failed to create Keycloak user', err);
|
||||
throw new Error('Failed to create user in Keycloak');
|
||||
}
|
||||
|
||||
// 3. Create user in local DB (keycloakId is the unique link to Keycloak)
|
||||
const adminRole = await this.prisma.role.findFirst({
|
||||
where: { name: 'admin' },
|
||||
});
|
||||
|
||||
const user = await this.prisma.user.upsert({
|
||||
where: { keycloakId: keycloakUserId },
|
||||
create: {
|
||||
email: dto.email,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
keycloakId: keycloakUserId,
|
||||
isActive: true,
|
||||
},
|
||||
update: {
|
||||
email: dto.email,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Assign admin role
|
||||
if (adminRole) {
|
||||
await this.prisma.userRole.upsert({
|
||||
where: {
|
||||
userId_roleId: { userId: user.id, roleId: adminRole.id },
|
||||
},
|
||||
create: { userId: user.id, roleId: adminRole.id },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Admin user created: ${dto.email} (keycloakId: ${keycloakUserId})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves branding settings (app name, company name, logo URL).
|
||||
*/
|
||||
async saveBranding(dto: SaveBrandingDto, token: string): Promise<void> {
|
||||
await this.validateToken(token);
|
||||
|
||||
await this.systemSettings.upsert('branding.appName', {
|
||||
value: dto.appName,
|
||||
category: 'branding',
|
||||
});
|
||||
|
||||
if (dto.companyName !== undefined) {
|
||||
await this.systemSettings.upsert('branding.companyName', {
|
||||
value: dto.companyName,
|
||||
category: 'branding',
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.logoUrl !== undefined) {
|
||||
await this.systemSettings.upsert('branding.logoUrl', {
|
||||
value: dto.logoUrl,
|
||||
category: 'branding',
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(`Branding settings saved: appName=${dto.appName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the setup as completed. After this, all setup endpoints
|
||||
* will reject further modifications.
|
||||
*/
|
||||
async completeSetup(token: string): Promise<void> {
|
||||
await this.validateToken(token);
|
||||
|
||||
await this.systemSettings.upsert('setup.completed', {
|
||||
value: 'true',
|
||||
category: 'system',
|
||||
valueType: 'boolean',
|
||||
});
|
||||
|
||||
this.logger.log('Setup completed successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the setup token and checks that setup has not been completed yet.
|
||||
*/
|
||||
private async validateToken(token: string): Promise<void> {
|
||||
const completed = await this.systemSettings.getValue('setup.completed');
|
||||
if (completed === 'true') {
|
||||
throw new ForbiddenException('Setup is already completed');
|
||||
}
|
||||
|
||||
const storedToken = await this.systemSettings.getValue('setup.token');
|
||||
if (!storedToken || storedToken !== token) {
|
||||
throw new UnauthorizedException('Invalid setup token');
|
||||
}
|
||||
}
|
||||
}
|
||||
66
apps/web/Dockerfile
Normal file
66
apps/web/Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
||||
# =============================================================================
|
||||
# tOS Web Frontend - Multi-Stage Docker Build
|
||||
# =============================================================================
|
||||
# Nutzt Next.js Standalone-Output fuer minimale Image-Groesse
|
||||
# Voraussetzung: output: 'standalone' in next.config.mjs
|
||||
#
|
||||
# Build: docker build -f apps/web/Dockerfile -t tos-web .
|
||||
# Run: docker run -p 3000:3000 --env-file .env tos-web
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: Base - Node.js mit pnpm
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: Builder - Dependencies installieren und kompilieren
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Kopiere Workspace-Konfiguration (fuer pnpm Monorepo-Aufloesung)
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
|
||||
# Kopiere Shared Package (wird vom Frontend als Dependency referenziert)
|
||||
COPY packages/ ./packages/
|
||||
|
||||
# Kopiere Frontend-Quellcode
|
||||
COPY apps/web/ ./apps/web/
|
||||
|
||||
# Installiere alle Dependencies (frozen-lockfile fuer reproduzierbare Builds)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Baue zuerst das Shared Package (Dependency des Frontends)
|
||||
RUN pnpm --filter @tos/shared build
|
||||
|
||||
# Baue das Frontend (erzeugt .next/standalone dank output: 'standalone')
|
||||
RUN pnpm --filter @tos/web build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3: Runner - Schlankes Production Image
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Kopiere Standalone-Output (enthaelt Server + gebundelte Dependencies)
|
||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||
|
||||
# Kopiere statische Assets (werden nicht im Standalone-Bundle enthalten)
|
||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
||||
|
||||
# Kopiere Public-Verzeichnis (Bilder, Fonts, etc.)
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
|
||||
# Sicherheit: Nicht als root ausfuehren
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Next.js Standalone-Server starten
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
@@ -1073,5 +1073,63 @@
|
||||
"saveError": "Fehler beim Speichern der Einstellungen",
|
||||
"requiresRestart": "Aenderung erfordert einen Neustart des Backends",
|
||||
"save": "Speichern"
|
||||
},
|
||||
"installer": {
|
||||
"title": "tOS Einrichtung",
|
||||
"setupComplete": "Einrichtung abgeschlossen",
|
||||
"notAccessible": "Nach der Einrichtung ist dieser Bereich nicht mehr zugaenglich.",
|
||||
"steps": {
|
||||
"systemCheck": "Systempruefung",
|
||||
"adminSetup": "Admin-Account",
|
||||
"branding": "Branding",
|
||||
"complete": "Abschluss"
|
||||
},
|
||||
"systemCheck": {
|
||||
"title": "Systemueberpruefung",
|
||||
"description": "Alle Dienste werden auf Erreichbarkeit geprueft.",
|
||||
"api": "API-Server",
|
||||
"database": "Datenbank",
|
||||
"keycloak": "Authentifizierung",
|
||||
"online": "Online",
|
||||
"offline": "Nicht erreichbar",
|
||||
"checking": "Wird geprueft...",
|
||||
"continue": "Weiter zur Einrichtung",
|
||||
"alreadyComplete": "Die Einrichtung wurde bereits abgeschlossen.",
|
||||
"redirecting": "Du wirst zum Dashboard weitergeleitet..."
|
||||
},
|
||||
"adminSetup": {
|
||||
"title": "Admin-Account erstellen",
|
||||
"description": "Erstelle den ersten Administrator-Account fuer tOS.",
|
||||
"firstName": "Vorname",
|
||||
"lastName": "Nachname",
|
||||
"email": "E-Mail-Adresse",
|
||||
"password": "Passwort",
|
||||
"passwordConfirm": "Passwort bestaetigen",
|
||||
"passwordMismatch": "Passwoerter stimmen nicht ueberein",
|
||||
"passwordTooShort": "Mindestens 8 Zeichen erforderlich",
|
||||
"createAccount": "Account erstellen",
|
||||
"creating": "Wird erstellt..."
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding konfigurieren",
|
||||
"description": "Passe tOS an dein Unternehmen an.",
|
||||
"appName": "App-Name",
|
||||
"appNamePlaceholder": "tOS",
|
||||
"companyName": "Firmenname",
|
||||
"companyNamePlaceholder": "Mein Unternehmen GmbH",
|
||||
"logoUrl": "Logo-URL",
|
||||
"logoUrlPlaceholder": "https://example.com/logo.png",
|
||||
"logoPreview": "Logo-Vorschau",
|
||||
"save": "Speichern & Weiter",
|
||||
"saving": "Wird gespeichert...",
|
||||
"skip": "Ueberspringen"
|
||||
},
|
||||
"complete": {
|
||||
"title": "Einrichtung abgeschlossen!",
|
||||
"description": "tOS wurde erfolgreich eingerichtet und ist bereit zur Nutzung.",
|
||||
"completing": "Einrichtung wird abgeschlossen...",
|
||||
"toDashboard": "Zum Dashboard",
|
||||
"toLogin": "Zum Login"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1073,5 +1073,63 @@
|
||||
"saveError": "Failed to save settings",
|
||||
"requiresRestart": "Change requires a backend restart",
|
||||
"save": "Save"
|
||||
},
|
||||
"installer": {
|
||||
"title": "tOS Setup",
|
||||
"setupComplete": "Setup Complete",
|
||||
"notAccessible": "After setup, this area will no longer be accessible.",
|
||||
"steps": {
|
||||
"systemCheck": "System Check",
|
||||
"adminSetup": "Admin Account",
|
||||
"branding": "Branding",
|
||||
"complete": "Complete"
|
||||
},
|
||||
"systemCheck": {
|
||||
"title": "System Check",
|
||||
"description": "Checking all services for availability.",
|
||||
"api": "API Server",
|
||||
"database": "Database",
|
||||
"keycloak": "Authentication",
|
||||
"online": "Online",
|
||||
"offline": "Unreachable",
|
||||
"checking": "Checking...",
|
||||
"continue": "Continue Setup",
|
||||
"alreadyComplete": "Setup has already been completed.",
|
||||
"redirecting": "Redirecting to dashboard..."
|
||||
},
|
||||
"adminSetup": {
|
||||
"title": "Create Admin Account",
|
||||
"description": "Create the first administrator account for tOS.",
|
||||
"firstName": "First Name",
|
||||
"lastName": "Last Name",
|
||||
"email": "Email Address",
|
||||
"password": "Password",
|
||||
"passwordConfirm": "Confirm Password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"passwordTooShort": "Minimum 8 characters required",
|
||||
"createAccount": "Create Account",
|
||||
"creating": "Creating..."
|
||||
},
|
||||
"branding": {
|
||||
"title": "Configure Branding",
|
||||
"description": "Customize tOS for your company.",
|
||||
"appName": "App Name",
|
||||
"appNamePlaceholder": "tOS",
|
||||
"companyName": "Company Name",
|
||||
"companyNamePlaceholder": "My Company Inc.",
|
||||
"logoUrl": "Logo URL",
|
||||
"logoUrlPlaceholder": "https://example.com/logo.png",
|
||||
"logoPreview": "Logo Preview",
|
||||
"save": "Save & Continue",
|
||||
"saving": "Saving...",
|
||||
"skip": "Skip"
|
||||
},
|
||||
"complete": {
|
||||
"title": "Setup Complete!",
|
||||
"description": "tOS has been successfully set up and is ready to use.",
|
||||
"completing": "Completing setup...",
|
||||
"toDashboard": "Go to Dashboard",
|
||||
"toLogin": "Go to Login"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ const nextConfig = {
|
||||
// Enable React strict mode for better development experience
|
||||
reactStrictMode: true,
|
||||
|
||||
// Standalone output for Docker deployment
|
||||
// Erzeugt ein eigenstaendiges Build-Artefakt mit allen Abhaengigkeiten
|
||||
output: 'standalone',
|
||||
|
||||
// Configure image optimization
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
Plug,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
LayoutGrid,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -41,7 +45,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { IntegrationStatusBadge } from '@/components/integrations';
|
||||
import { IntegrationStatusBadge, IntegrationCard } from '@/components/integrations';
|
||||
import { useAllIntegrationStatuses } from '@/hooks/integrations';
|
||||
import {
|
||||
useCredentials,
|
||||
@@ -459,11 +463,14 @@ function IntegrationPanel({ integrationType }: IntegrationPanelProps) {
|
||||
* Lists each integration as a tab; each tab loads its own credential
|
||||
* state so queries are isolated and only triggered when the tab is visited.
|
||||
*/
|
||||
export function AdminIntegrationsContent({ locale: _locale }: AdminIntegrationsContentProps) {
|
||||
export function AdminIntegrationsContent({ locale }: AdminIntegrationsContentProps) {
|
||||
const t = useTranslations('integrations');
|
||||
const tAdmin = useTranslations('admin');
|
||||
const { data: integrations, isLoading } = useAllIntegrationStatuses();
|
||||
|
||||
const connectedCount = integrations?.filter((i) => i.status === 'connected').length ?? 0;
|
||||
const errorCount = integrations?.filter((i) => i.status === 'error').length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-8 py-6">
|
||||
{/* Header */}
|
||||
@@ -501,8 +508,12 @@ export function AdminIntegrationsContent({ locale: _locale }: AdminIntegrationsC
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="plentyone" className="space-y-4">
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
<TabsList className="flex-wrap">
|
||||
<TabsTrigger value="overview" className="gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
{t('overview')}
|
||||
</TabsTrigger>
|
||||
{integrations?.map((config) => {
|
||||
const meta = integrationMeta[config.type];
|
||||
const Icon = meta.icon;
|
||||
@@ -515,6 +526,78 @@ export function AdminIntegrationsContent({ locale: _locale }: AdminIntegrationsC
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
{/* Overview tab */}
|
||||
<TabsContent value="overview">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Summary Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('allIntegrations')}</CardTitle>
|
||||
<Plug className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{integrations?.length ?? 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('connected')}</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-500">{connectedCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('error')}</CardTitle>
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={cn('text-2xl font-bold', errorCount > 0 && 'text-destructive')}>
|
||||
{errorCount}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Integration Cards Grid */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 },
|
||||
},
|
||||
}}
|
||||
className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{integrations?.map((config) => (
|
||||
<motion.div
|
||||
key={config.id}
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
}}
|
||||
>
|
||||
<IntegrationCard config={config} locale={locale} showActions={false} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Individual integration tabs */}
|
||||
{integrations?.map((config) => (
|
||||
<TabsContent key={config.type} value={config.type}>
|
||||
<motion.div
|
||||
|
||||
225
apps/web/src/app/[locale]/(setup)/admin/page.tsx
Normal file
225
apps/web/src/app/[locale]/(setup)/admin/page.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Eye, EyeOff, Loader2, UserPlus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function AdminSetupPage() {
|
||||
const t = useTranslations('installer.adminSetup');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!form.firstName.trim()) newErrors.firstName = 'Required';
|
||||
if (!form.lastName.trim()) newErrors.lastName = 'Required';
|
||||
if (!form.email.trim()) newErrors.email = 'Required';
|
||||
if (form.password.length < 8) newErrors.password = t('passwordTooShort');
|
||||
if (form.password !== form.passwordConfirm) newErrors.passwordConfirm = t('passwordMismatch');
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
const token = sessionStorage.getItem('setup-token') ?? '';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/setup/admin`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-setup-token': token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.message ?? `Error ${res.status}`);
|
||||
}
|
||||
|
||||
router.push(`/${locale}/setup/branding`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Fehler',
|
||||
description: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">{t('firstName')}</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={form.firstName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
|
||||
className={cn(errors.firstName && 'border-destructive')}
|
||||
/>
|
||||
{errors.firstName && (
|
||||
<p className="text-xs text-destructive">{errors.firstName}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">{t('lastName')}</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={form.lastName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
|
||||
className={cn(errors.lastName && 'border-destructive')}
|
||||
/>
|
||||
{errors.lastName && (
|
||||
<p className="text-xs text-destructive">{errors.lastName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t('email')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
className={cn(errors.email && 'border-destructive')}
|
||||
/>
|
||||
{errors.email && <p className="text-xs text-destructive">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t('password')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
className={cn('pr-10', errors.password && 'border-destructive')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="passwordConfirm">{t('passwordConfirm')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="passwordConfirm"
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
value={form.passwordConfirm}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, passwordConfirm: e.target.value }))
|
||||
}
|
||||
className={cn('pr-10', errors.passwordConfirm && 'border-destructive')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => setShowConfirm((s) => !s)}
|
||||
>
|
||||
{showConfirm ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.passwordConfirm && (
|
||||
<p className="text-xs text-destructive">{errors.passwordConfirm}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t('createAccount')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
144
apps/web/src/app/[locale]/(setup)/branding/page.tsx
Normal file
144
apps/web/src/app/[locale]/(setup)/branding/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Loader2, Palette, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export default function BrandingPage() {
|
||||
const t = useTranslations('installer.branding');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [form, setForm] = useState({ appName: 'tOS', companyName: '', logoUrl: '' });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true);
|
||||
const token = sessionStorage.getItem('setup-token') ?? '';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1';
|
||||
|
||||
try {
|
||||
const body: Record<string, string> = { appName: form.appName };
|
||||
if (form.companyName) body.companyName = form.companyName;
|
||||
if (form.logoUrl) body.logoUrl = form.logoUrl;
|
||||
|
||||
const res = await fetch(`${apiUrl}/setup/branding`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-setup-token': token,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||
router.push(`/${locale}/setup/complete`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Fehler',
|
||||
description: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => router.push(`/${locale}/setup/complete`);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="appName">{t('appName')}</Label>
|
||||
<Input
|
||||
id="appName"
|
||||
placeholder={t('appNamePlaceholder')}
|
||||
value={form.appName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, appName: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="companyName">{t('companyName')}</Label>
|
||||
<Input
|
||||
id="companyName"
|
||||
placeholder={t('companyNamePlaceholder')}
|
||||
value={form.companyName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, companyName: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logoUrl">{t('logoUrl')}</Label>
|
||||
<Input
|
||||
id="logoUrl"
|
||||
type="url"
|
||||
placeholder={t('logoUrlPlaceholder')}
|
||||
value={form.logoUrl}
|
||||
onChange={(e) => setForm((f) => ({ ...f, logoUrl: e.target.value }))}
|
||||
/>
|
||||
{form.logoUrl && (
|
||||
<div className="mt-3 p-3 border rounded-lg bg-muted/30">
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('logoPreview')}</p>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={form.logoUrl}
|
||||
alt="Logo Preview"
|
||||
className="h-12 max-w-48 object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
<Button variant="ghost" onClick={handleSkip}>
|
||||
{t('skip')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading || !form.appName.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
{t('save')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
89
apps/web/src/app/[locale]/(setup)/complete/page.tsx
Normal file
89
apps/web/src/app/[locale]/(setup)/complete/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CheckCircle2, LayoutDashboard, LogIn, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
export default function CompletePage() {
|
||||
const t = useTranslations('installer.complete');
|
||||
const locale = useLocale();
|
||||
const [isCompleting, setIsCompleting] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
completeSetup();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const completeSetup = async () => {
|
||||
const token = sessionStorage.getItem('setup-token') ?? '';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1';
|
||||
|
||||
try {
|
||||
await fetch(`${apiUrl}/setup/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-setup-token': token },
|
||||
});
|
||||
} catch {
|
||||
// Best-effort completion
|
||||
} finally {
|
||||
sessionStorage.removeItem('setup-token');
|
||||
setIsCompleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isCompleting) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-16 flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">{t('completing')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="py-12 flex flex-col items-center gap-6 text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', delay: 0.1 }}
|
||||
>
|
||||
<CheckCircle2 className="h-20 w-20 text-green-500" />
|
||||
</motion.div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{t('title')}</h2>
|
||||
<p className="mt-2 text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${locale}/login`}>
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
{t('toLogin')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={`/${locale}/dashboard`}>
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
{t('toDashboard')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
17
apps/web/src/app/[locale]/(setup)/layout.tsx
Normal file
17
apps/web/src/app/[locale]/(setup)/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { SetupLayoutContent } from './setup-layout-content';
|
||||
|
||||
interface SetupLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: { locale: string };
|
||||
}
|
||||
|
||||
export default async function SetupLayout({ children, params }: SetupLayoutProps) {
|
||||
const t = await getTranslations({ locale: params.locale, namespace: 'installer' });
|
||||
|
||||
return (
|
||||
<SetupLayoutContent locale={params.locale} title={t('title')} notAccessible={t('notAccessible')}>
|
||||
{children}
|
||||
</SetupLayoutContent>
|
||||
);
|
||||
}
|
||||
176
apps/web/src/app/[locale]/(setup)/page.tsx
Normal file
176
apps/web/src/app/[locale]/(setup)/page.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CheckCircle2, XCircle, Loader2, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
type ServiceStatus = 'checking' | 'online' | 'offline';
|
||||
|
||||
interface StatusRowProps {
|
||||
label: string;
|
||||
status: ServiceStatus;
|
||||
onlineLabel: string;
|
||||
offlineLabel: string;
|
||||
checkingLabel: string;
|
||||
}
|
||||
|
||||
function StatusRow({ label, status, onlineLabel, offlineLabel, checkingLabel }: StatusRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b last:border-0">
|
||||
<span className="font-medium">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{status === 'checking' && (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{checkingLabel}</span>
|
||||
</>
|
||||
)}
|
||||
{status === 'online' && (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm text-green-600 font-medium">{onlineLabel}</span>
|
||||
</>
|
||||
)}
|
||||
{status === 'offline' && (
|
||||
<>
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
<span className="text-sm text-destructive font-medium">{offlineLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SetupPage() {
|
||||
const t = useTranslations('installer.systemCheck');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [apiStatus, setApiStatus] = useState<ServiceStatus>('checking');
|
||||
const [dbStatus, setDbStatus] = useState<ServiceStatus>('checking');
|
||||
const [keycloakStatus, setKeycloakStatus] = useState<ServiceStatus>('checking');
|
||||
const [setupComplete, setSetupComplete] = useState(false);
|
||||
const [allOnline, setAllOnline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Store setup token from URL in sessionStorage
|
||||
const token = searchParams.get('token');
|
||||
if (token) {
|
||||
sessionStorage.setItem('setup-token', token);
|
||||
}
|
||||
|
||||
checkServices();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiStatus === 'online' && dbStatus === 'online' && keycloakStatus === 'online') {
|
||||
setAllOnline(true);
|
||||
}
|
||||
}, [apiStatus, dbStatus, keycloakStatus]);
|
||||
|
||||
const checkServices = async () => {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1';
|
||||
|
||||
// Check API
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/health/liveness`);
|
||||
if (res.ok) {
|
||||
setApiStatus('online');
|
||||
setDbStatus('online'); // If API is up, DB is connected
|
||||
} else {
|
||||
setApiStatus('offline');
|
||||
setDbStatus('offline');
|
||||
}
|
||||
} catch {
|
||||
setApiStatus('offline');
|
||||
setDbStatus('offline');
|
||||
}
|
||||
|
||||
// Check Setup Status
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/setup/status`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Handle response envelope
|
||||
const status = data.data ?? data;
|
||||
if (status.completed) {
|
||||
setSetupComplete(true);
|
||||
setTimeout(() => router.push(`/${locale}/dashboard`), 3000);
|
||||
return;
|
||||
}
|
||||
setKeycloakStatus('online');
|
||||
} else {
|
||||
setKeycloakStatus('offline');
|
||||
}
|
||||
} catch {
|
||||
setKeycloakStatus('offline');
|
||||
}
|
||||
};
|
||||
|
||||
if (setupComplete) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<CheckCircle2 className="mx-auto h-12 w-12 text-green-500 mb-4" />
|
||||
<p className="font-medium">{t('alreadyComplete')}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('redirecting')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<StatusRow
|
||||
label={t('api')}
|
||||
status={apiStatus}
|
||||
onlineLabel={t('online')}
|
||||
offlineLabel={t('offline')}
|
||||
checkingLabel={t('checking')}
|
||||
/>
|
||||
<StatusRow
|
||||
label={t('database')}
|
||||
status={dbStatus}
|
||||
onlineLabel={t('online')}
|
||||
offlineLabel={t('offline')}
|
||||
checkingLabel={t('checking')}
|
||||
/>
|
||||
<StatusRow
|
||||
label={t('keycloak')}
|
||||
status={keycloakStatus}
|
||||
onlineLabel={t('online')}
|
||||
offlineLabel={t('offline')}
|
||||
checkingLabel={t('checking')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={() => router.push(`/${locale}/setup/admin`)} disabled={!allOnline}>
|
||||
{t('continue')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
92
apps/web/src/app/[locale]/(setup)/setup-layout-content.tsx
Normal file
92
apps/web/src/app/[locale]/(setup)/setup-layout-content.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
interface SetupLayoutContentProps {
|
||||
children: React.ReactNode;
|
||||
locale: string;
|
||||
title: string;
|
||||
notAccessible: string;
|
||||
}
|
||||
|
||||
const steps = ['systemCheck', 'adminSetup', 'branding', 'complete'] as const;
|
||||
|
||||
export function SetupLayoutContent({ children, title, notAccessible }: SetupLayoutContentProps) {
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations('installer.steps');
|
||||
|
||||
// Determine the active step index based on the current URL path
|
||||
let activeStep = 0;
|
||||
if (pathname.includes('/complete')) activeStep = 3;
|
||||
else if (pathname.includes('/branding')) activeStep = 2;
|
||||
else if (pathname.includes('/admin')) activeStep = 1;
|
||||
else activeStep = 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-card">
|
||||
<div className="container mx-auto px-4 py-4 flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold text-sm">
|
||||
t
|
||||
</div>
|
||||
<span className="text-xl font-bold">OS</span>
|
||||
<span className="text-muted-foreground mx-2">·</span>
|
||||
<span className="text-muted-foreground">{title}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="border-b bg-muted/30">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium transition-colors',
|
||||
index < activeStep && 'bg-primary text-primary-foreground',
|
||||
index === activeStep &&
|
||||
'bg-primary text-primary-foreground ring-2 ring-primary ring-offset-2',
|
||||
index > activeStep && 'bg-muted text-muted-foreground border'
|
||||
)}
|
||||
>
|
||||
{index < activeStep ? <Check className="h-3.5 w-3.5" /> : index + 1}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm hidden sm:block',
|
||||
index === activeStep ? 'font-medium text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{t(step)}
|
||||
</span>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-px w-8 sm:w-16 mx-1',
|
||||
index < activeStep ? 'bg-primary' : 'bg-border'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 container mx-auto px-4 py-8 max-w-2xl">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t py-4">
|
||||
<div className="container mx-auto px-4 text-center text-xs text-muted-foreground">
|
||||
{notAccessible}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
Network,
|
||||
Building2,
|
||||
UserPlus,
|
||||
MessageSquare,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -106,30 +105,6 @@ const mainNavItems: NavItem[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'integrations',
|
||||
href: '/integrations',
|
||||
icon: Plug,
|
||||
requiredRoles: ['manager', 'admin'],
|
||||
children: [
|
||||
{
|
||||
key: 'overview',
|
||||
href: '/integrations',
|
||||
icon: Plug,
|
||||
exactMatch: true,
|
||||
},
|
||||
{
|
||||
key: 'plentyOne',
|
||||
href: '/integrations/plentyone',
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
key: 'zulip',
|
||||
href: '/integrations/zulip',
|
||||
icon: MessageSquare,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const bottomNavItems: NavItem[] = [
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { locales, defaultLocale } from './i18n';
|
||||
|
||||
// Public paths that don't require authentication
|
||||
const publicPages = ['/login'];
|
||||
const publicPages = ['/login', '/setup'];
|
||||
|
||||
// Create the next-intl middleware
|
||||
const intlMiddleware = createMiddleware({
|
||||
|
||||
46
docker/.env.prod.example
Normal file
46
docker/.env.prod.example
Normal file
@@ -0,0 +1,46 @@
|
||||
# =============================================================================
|
||||
# tOS Production Configuration
|
||||
# =============================================================================
|
||||
# Kopiere diese Datei nach docker/.env und fuelle alle Werte aus.
|
||||
# Alternativ: Nutze install.sh fuer eine interaktive Einrichtung.
|
||||
#
|
||||
# Secrets generieren mit: openssl rand -hex 32
|
||||
# =============================================================================
|
||||
|
||||
# ---- Application Domain ----------------------------------------------------
|
||||
APP_DOMAIN=tos.example.com
|
||||
LETSENCRYPT_EMAIL=admin@example.com
|
||||
|
||||
# ---- PostgreSQL -------------------------------------------------------------
|
||||
POSTGRES_USER=tos_user
|
||||
POSTGRES_PASSWORD=CHANGE_ME_run_openssl_rand_hex_32
|
||||
POSTGRES_DB=tos_db
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# ---- Redis ------------------------------------------------------------------
|
||||
REDIS_PORT=6379
|
||||
|
||||
# ---- Keycloak ---------------------------------------------------------------
|
||||
KEYCLOAK_ADMIN=admin
|
||||
KEYCLOAK_ADMIN_PASSWORD=CHANGE_ME_run_openssl_rand_hex_32
|
||||
KEYCLOAK_PORT=8080
|
||||
KEYCLOAK_REALM=tOS
|
||||
|
||||
# ---- Application Secrets ---------------------------------------------------
|
||||
# Jedes Secret separat generieren: openssl rand -hex 32
|
||||
JWT_SECRET=CHANGE_ME_run_openssl_rand_hex_32
|
||||
ENCRYPTION_KEY=CHANGE_ME_run_openssl_rand_hex_32
|
||||
NEXTAUTH_SECRET=CHANGE_ME_run_openssl_rand_hex_32
|
||||
|
||||
# ---- Keycloak OAuth Clients ------------------------------------------------
|
||||
# Nach dem ersten Keycloak-Start aus der Admin-UI auslesen:
|
||||
# https://auth.<APP_DOMAIN>/admin/master/console/#/tOS/clients
|
||||
KEYCLOAK_CLIENT_ID=tos-backend
|
||||
KEYCLOAK_CLIENT_SECRET=CHANGE_ME_from_keycloak_admin_ui
|
||||
NEXTAUTH_KEYCLOAK_CLIENT_ID=tos-nextauth
|
||||
NEXTAUTH_KEYCLOAK_CLIENT_SECRET=CHANGE_ME_from_keycloak_admin_ui
|
||||
|
||||
# ---- Setup Token ------------------------------------------------------------
|
||||
# Wird fuer die initiale Einrichtung benoetigt. Nach dem Setup entfernen.
|
||||
# Generieren mit: uuidgen || openssl rand -hex 16
|
||||
SETUP_TOKEN=CHANGE_ME_generated_by_install_script
|
||||
30
docker/Caddyfile
Normal file
30
docker/Caddyfile
Normal file
@@ -0,0 +1,30 @@
|
||||
# =============================================================================
|
||||
# tOS Caddy Reverse Proxy Konfiguration
|
||||
# =============================================================================
|
||||
# Caddy uebernimmt automatisch Let's Encrypt Zertifikate und HTTPS-Terminierung.
|
||||
#
|
||||
# Routing:
|
||||
# {APP_DOMAIN} -> Web Frontend (Next.js)
|
||||
# {APP_DOMAIN}/api/* -> API Backend (NestJS)
|
||||
# auth.{APP_DOMAIN} -> Keycloak Identity Provider
|
||||
# =============================================================================
|
||||
{
|
||||
email {$LETSENCRYPT_EMAIL}
|
||||
}
|
||||
|
||||
# Haupt-Domain: Frontend + API
|
||||
{$APP_DOMAIN} {
|
||||
# API-Requests an das NestJS Backend weiterleiten
|
||||
handle /api/* {
|
||||
reverse_proxy api:3001
|
||||
}
|
||||
|
||||
# Alle anderen Requests an das Next.js Frontend
|
||||
reverse_proxy web:3000
|
||||
}
|
||||
|
||||
# Auth-Subdomain: Keycloak
|
||||
auth.{$APP_DOMAIN} {
|
||||
reverse_proxy keycloak:8080
|
||||
}
|
||||
}
|
||||
200
docker/docker-compose.local.yml
Normal file
200
docker/docker-compose.local.yml
Normal file
@@ -0,0 +1,200 @@
|
||||
# =============================================================================
|
||||
# tOS Local Docker Compose (Full-Stack)
|
||||
# =============================================================================
|
||||
# Lokaler Full-Stack zum Testen der containerisierten Anwendung.
|
||||
# Alle Services laufen containerisiert mit exponierten Ports.
|
||||
#
|
||||
# Start: docker compose -f docker-compose.local.yml up -d --build
|
||||
# Stop: docker compose -f docker-compose.local.yml down
|
||||
# Reset: docker compose -f docker-compose.local.yml down -v
|
||||
#
|
||||
# Unterschied zum Dev-Stack (docker-compose.yml):
|
||||
# - Dev-Stack: Nur Infra (Postgres, Redis, Keycloak), Apps laufen nativ
|
||||
# - Local-Stack: Alle Services containerisiert (nahe an Produktion)
|
||||
# =============================================================================
|
||||
|
||||
name: tos-local
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL Database
|
||||
# ---------------------------------------------------------------------------
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: tos-postgres-local
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-tos_user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tos_local_password}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-tos_db}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_local_data:/var/lib/postgresql/data
|
||||
- ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tos_user} -d ${POSTGRES_DB:-tos_db}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
networks:
|
||||
- tos-local-network
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis Cache & Queue
|
||||
# ---------------------------------------------------------------------------
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: tos-redis-local
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_local_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- tos-local-network
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Keycloak Identity & Access Management (Dev Mode)
|
||||
# ---------------------------------------------------------------------------
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:24.0
|
||||
container_name: tos-keycloak-local
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- start-dev
|
||||
- --import-realm
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
|
||||
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin123}
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-tos_db}
|
||||
KC_DB_USERNAME: ${POSTGRES_USER:-tos_user}
|
||||
KC_DB_PASSWORD: ${POSTGRES_PASSWORD:-tos_local_password}
|
||||
KC_HOSTNAME: localhost
|
||||
KC_HOSTNAME_STRICT: "false"
|
||||
KC_HOSTNAME_STRICT_HTTPS: "false"
|
||||
KC_HTTP_ENABLED: "true"
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
||||
healthcheck:
|
||||
# Keycloak 24+ (UBI9) hat kein curl - nutze bash TCP redirect
|
||||
test: >
|
||||
bash -c 'exec 3<>/dev/tcp/localhost/8080 &&
|
||||
echo -e "GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3 &&
|
||||
timeout 2 cat <&3 | grep -q "200 OK"'
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 5
|
||||
start_period: 90s
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tos-local-network
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tOS API (NestJS Backend)
|
||||
# ---------------------------------------------------------------------------
|
||||
api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/api/Dockerfile
|
||||
container_name: tos-api-local
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3001"
|
||||
API_PREFIX: api
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-tos_user}:${POSTGRES_PASSWORD:-tos_local_password}@postgres:5432/tos_app
|
||||
JWT_SECRET: ${JWT_SECRET:-local-jwt-secret-not-for-production-use}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-local-encryption-key-32-bytes-long!!}
|
||||
KEYCLOAK_URL: http://keycloak:8080
|
||||
KEYCLOAK_REALM: tOS
|
||||
KEYCLOAK_CLIENT_ID: tos-backend
|
||||
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET:-}
|
||||
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin123}
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: "6379"
|
||||
SETUP_TOKEN: ${SETUP_TOKEN:-local-setup-token-for-testing}
|
||||
ports:
|
||||
- "3001:3001"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/v1/health/liveness || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
networks:
|
||||
- tos-local-network
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tOS Web Frontend (Next.js)
|
||||
# ---------------------------------------------------------------------------
|
||||
# HINWEIS zum Keycloak-Issuer-Problem in Docker:
|
||||
# Next.js/NextAuth validiert Tokens server-seitig gegen den Issuer.
|
||||
# Der Browser erreicht Keycloak ueber localhost:8080, aber der Container
|
||||
# muss ueber den Docker-Netzwerknamen "keycloak:8080" zugreifen.
|
||||
#
|
||||
# Loesung: KEYCLOAK_ISSUER fuer Browser-Redirects (localhost),
|
||||
# NEXTAUTH_KEYCLOAK_ISSUER fuer server-seitige Validierung (Docker-intern)
|
||||
# ---------------------------------------------------------------------------
|
||||
web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
container_name: tos-web-local
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_API_URL: http://localhost:3001/api/v1
|
||||
NEXTAUTH_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-local-nextauth-secret}
|
||||
KEYCLOAK_CLIENT_ID: tos-nextauth
|
||||
KEYCLOAK_CLIENT_SECRET: ${NEXTAUTH_KEYCLOAK_CLIENT_SECRET:-tos-nextauth-secret-dev}
|
||||
# Browser-seitige Redirects: localhost (erreichbar vom Host)
|
||||
KEYCLOAK_ISSUER: http://localhost:8080/realms/tOS
|
||||
# Server-seitige Token-Validierung: Docker-internes Netzwerk
|
||||
NEXTAUTH_KEYCLOAK_ISSUER: http://keycloak:8080/realms/tOS
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tos-local-network
|
||||
|
||||
# =============================================================================
|
||||
# Volumes
|
||||
# =============================================================================
|
||||
volumes:
|
||||
postgres_local_data:
|
||||
name: tos-local-postgres-data
|
||||
redis_local_data:
|
||||
name: tos-local-redis-data
|
||||
|
||||
# =============================================================================
|
||||
# Networks
|
||||
# =============================================================================
|
||||
networks:
|
||||
tos-local-network:
|
||||
name: tos-local-network
|
||||
driver: bridge
|
||||
218
docker/docker-compose.prod.yml
Normal file
218
docker/docker-compose.prod.yml
Normal file
@@ -0,0 +1,218 @@
|
||||
# =============================================================================
|
||||
# tOS Production Docker Compose
|
||||
# =============================================================================
|
||||
# Vollstaendiger Produktions-Stack mit optionalem SSL via Caddy.
|
||||
#
|
||||
# Ohne SSL (externer Reverse Proxy):
|
||||
# docker compose -f docker-compose.prod.yml up -d
|
||||
#
|
||||
# Mit Let's Encrypt SSL (Caddy):
|
||||
# docker compose -f docker-compose.prod.yml --profile ssl up -d
|
||||
#
|
||||
# Voraussetzungen:
|
||||
# - docker/.env mit allen Secrets (erstellt durch install.sh)
|
||||
# - Docker Images gebaut (api + web)
|
||||
# =============================================================================
|
||||
|
||||
name: tos-prod
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL Database
|
||||
# ---------------------------------------------------------------------------
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: tos-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-tos_user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-tos_db}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tos_user} -d ${POSTGRES_DB:-tos_db}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
networks:
|
||||
- tos-network
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis Cache & Queue
|
||||
# ---------------------------------------------------------------------------
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: tos-redis
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
redis-server
|
||||
--appendonly yes
|
||||
--maxmemory 256mb
|
||||
--maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- tos-network
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Keycloak Identity & Access Management (Production Mode)
|
||||
# ---------------------------------------------------------------------------
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:24.0
|
||||
container_name: tos-keycloak
|
||||
restart: unless-stopped
|
||||
# "start" statt "start-dev" fuer Production (aktiviert Caching, deaktiviert Dev-Features)
|
||||
command:
|
||||
- start
|
||||
- --import-realm
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
|
||||
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-tos_db}
|
||||
KC_DB_USERNAME: ${POSTGRES_USER:-tos_user}
|
||||
KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
# Hostname-Konfiguration fuer Production hinter Reverse Proxy
|
||||
KC_HOSTNAME: auth.${APP_DOMAIN}
|
||||
KC_HOSTNAME_STRICT: "true"
|
||||
KC_HOSTNAME_STRICT_HTTPS: "true"
|
||||
KC_HTTP_ENABLED: "true"
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
KC_PROXY: edge
|
||||
volumes:
|
||||
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
||||
healthcheck:
|
||||
# Keycloak 24+ (UBI9) hat kein curl - nutze bash TCP redirect
|
||||
test: >
|
||||
bash -c 'exec 3<>/dev/tcp/localhost/8080 &&
|
||||
echo -e "GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3 &&
|
||||
timeout 2 cat <&3 | grep -q "200 OK"'
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tos-network
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tOS API (NestJS Backend)
|
||||
# ---------------------------------------------------------------------------
|
||||
api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/api/Dockerfile
|
||||
container_name: tos-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3001"
|
||||
API_PREFIX: api
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-tos_user}:${POSTGRES_PASSWORD}@postgres:5432/tos_app
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
KEYCLOAK_URL: http://keycloak:8080
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM:-tOS}
|
||||
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-tos-backend}
|
||||
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET:-}
|
||||
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: "6379"
|
||||
SETUP_TOKEN: ${SETUP_TOKEN}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/v1/health/liveness || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
networks:
|
||||
- tos-network
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tOS Web Frontend (Next.js)
|
||||
# ---------------------------------------------------------------------------
|
||||
web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
container_name: tos-web
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NEXT_PUBLIC_APP_URL: https://${APP_DOMAIN}
|
||||
NEXT_PUBLIC_API_URL: https://${APP_DOMAIN}/api/v1
|
||||
NEXTAUTH_URL: https://${APP_DOMAIN}
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||
KEYCLOAK_CLIENT_ID: ${NEXTAUTH_KEYCLOAK_CLIENT_ID:-tos-nextauth}
|
||||
KEYCLOAK_CLIENT_SECRET: ${NEXTAUTH_KEYCLOAK_CLIENT_SECRET:-}
|
||||
# Browser-seitige Redirects: oeffentliche URL
|
||||
KEYCLOAK_ISSUER: https://auth.${APP_DOMAIN}/realms/${KEYCLOAK_REALM:-tOS}
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tos-network
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Caddy Reverse Proxy (optional, nur mit --profile ssl)
|
||||
# ---------------------------------------------------------------------------
|
||||
caddy:
|
||||
profiles: ["ssl"]
|
||||
image: caddy:2-alpine
|
||||
container_name: tos-caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
environment:
|
||||
APP_DOMAIN: ${APP_DOMAIN}
|
||||
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL:-}
|
||||
depends_on:
|
||||
- web
|
||||
- api
|
||||
- keycloak
|
||||
networks:
|
||||
- tos-network
|
||||
|
||||
# =============================================================================
|
||||
# Volumes
|
||||
# =============================================================================
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: tos-postgres-data
|
||||
redis_data:
|
||||
name: tos-redis-data
|
||||
caddy_data:
|
||||
name: tos-caddy-data
|
||||
caddy_config:
|
||||
name: tos-caddy-config
|
||||
|
||||
# =============================================================================
|
||||
# Networks
|
||||
# =============================================================================
|
||||
networks:
|
||||
tos-network:
|
||||
name: tos-network
|
||||
driver: bridge
|
||||
@@ -27,6 +27,7 @@ services:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tos_user} -d ${POSTGRES_DB:-tos_db}"]
|
||||
interval: 10s
|
||||
|
||||
16
docker/postgres/init.sql
Normal file
16
docker/postgres/init.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- =============================================================================
|
||||
-- tOS PostgreSQL Initialisierung
|
||||
-- =============================================================================
|
||||
-- Diese Datei wird automatisch beim ersten Start des PostgreSQL-Containers
|
||||
-- ausgefuehrt (via /docker-entrypoint-initdb.d/).
|
||||
--
|
||||
-- Keycloak verwendet die Standard-Datenbank "tos_db" (POSTGRES_DB).
|
||||
-- Die Applikation (NestJS/Prisma) benoetigt eine separate Datenbank "tos_app",
|
||||
-- um Datenisolation zwischen IAM und Geschaeftslogik sicherzustellen.
|
||||
-- =============================================================================
|
||||
|
||||
-- Erstelle die Applikations-Datenbank (getrennt von Keycloaks tos_db)
|
||||
CREATE DATABASE tos_app;
|
||||
|
||||
-- Stelle sicher, dass der Standard-User vollen Zugriff auf tos_app hat
|
||||
GRANT ALL PRIVILEGES ON DATABASE tos_app TO tos_user;
|
||||
232
install.sh
Executable file
232
install.sh
Executable file
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# tOS Installation Script
|
||||
# =============================================================================
|
||||
# Interaktiver Installationsassistent fuer das tOS Production Deployment.
|
||||
#
|
||||
# Ausfuehren mit: chmod +x install.sh && ./install.sh
|
||||
#
|
||||
# Das Script:
|
||||
# 1. Prueft Voraussetzungen (Docker, Docker Compose, openssl)
|
||||
# 2. Sammelt Konfiguration (Domain, SSL-Modus)
|
||||
# 3. Generiert kryptographische Secrets
|
||||
# 4. Startet alle Services
|
||||
# 5. Wartet auf Bereitschaft und zeigt Setup-URL an
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# ANSI Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
banner() {
|
||||
echo -e "${BLUE}${BOLD}"
|
||||
echo " ████████╗ ██████╗ ███████╗"
|
||||
echo " ██╔══╝██╔═══██╗██╔════╝"
|
||||
echo " ██║ ██║ ██║███████╗"
|
||||
echo " ██║ ██║ ██║╚════██║"
|
||||
echo " ██║ ╚██████╔╝███████║"
|
||||
echo " ╚═╝ ╚═════╝ ╚══════╝"
|
||||
echo -e "${NC}"
|
||||
echo -e "${BOLD} tOS - Enterprise Web Operating System${NC}"
|
||||
echo -e " Installationsassistent"
|
||||
echo ""
|
||||
}
|
||||
|
||||
check_prerequisites() {
|
||||
echo -e "${BOLD}[1/4] Voraussetzungen pruefen...${NC}"
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}[FEHLER] Docker nicht gefunden. Bitte installiere Docker: https://docs.docker.com/get-docker/${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}[OK] Docker gefunden: $(docker --version)${NC}"
|
||||
|
||||
if ! docker compose version &> /dev/null; then
|
||||
echo -e "${RED}[FEHLER] Docker Compose Plugin nicht gefunden. Bitte aktualisiere Docker.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}[OK] Docker Compose gefunden: $(docker compose version --short)${NC}"
|
||||
|
||||
if ! command -v openssl &> /dev/null; then
|
||||
echo -e "${RED}[FEHLER] openssl nicht gefunden. Bitte installiere openssl.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}[OK] openssl gefunden${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
collect_configuration() {
|
||||
echo -e "${BOLD}[2/4] Konfiguration${NC}"
|
||||
|
||||
echo -e "Domain fuer tOS (z.B. tos.meinefirma.de):"
|
||||
read -r -p " Domain: " APP_DOMAIN
|
||||
APP_DOMAIN="${APP_DOMAIN:-tos.example.com}"
|
||||
|
||||
echo ""
|
||||
echo -e "SSL-Konfiguration:"
|
||||
echo " [1] Let's Encrypt (Caddy - empfohlen fuer oeffentliche Domain)"
|
||||
echo " [2] Externer Reverse Proxy (nginx, Apache, Cloudflare etc.)"
|
||||
read -r -p " Auswahl [1/2]: " SSL_CHOICE
|
||||
|
||||
SSL_MODE="external"
|
||||
LETSENCRYPT_EMAIL=""
|
||||
if [[ "${SSL_CHOICE}" == "1" ]]; then
|
||||
SSL_MODE="letsencrypt"
|
||||
read -r -p " E-Mail fuer Let's Encrypt: " LETSENCRYPT_EMAIL
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
generate_secrets() {
|
||||
echo -e "${BOLD}[3/4] Secrets generieren...${NC}"
|
||||
|
||||
POSTGRES_PASSWORD=$(openssl rand -hex 32)
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
NEXTAUTH_SECRET=$(openssl rand -hex 32)
|
||||
KEYCLOAK_ADMIN_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
|
||||
|
||||
# Generate SETUP_TOKEN
|
||||
if command -v uuidgen &> /dev/null; then
|
||||
SETUP_TOKEN=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
else
|
||||
SETUP_TOKEN=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || openssl rand -hex 16)
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[OK] Secrets generiert${NC}"
|
||||
echo ""
|
||||
|
||||
# docker/.env erstellen
|
||||
cat > "${SCRIPT_DIR}/docker/.env" << EOF
|
||||
# =============================================================================
|
||||
# tOS Production Configuration - Generated by install.sh on $(date)
|
||||
# =============================================================================
|
||||
|
||||
# ---- Domain -----------------------------------------------------------------
|
||||
APP_DOMAIN=${APP_DOMAIN}
|
||||
LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL}
|
||||
|
||||
# ---- PostgreSQL -------------------------------------------------------------
|
||||
POSTGRES_USER=tos_user
|
||||
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB=tos_db
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# ---- Redis ------------------------------------------------------------------
|
||||
REDIS_PORT=6379
|
||||
|
||||
# ---- Keycloak ---------------------------------------------------------------
|
||||
KEYCLOAK_ADMIN=admin
|
||||
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD}
|
||||
KEYCLOAK_PORT=8080
|
||||
KEYCLOAK_REALM=tOS
|
||||
|
||||
# ---- Application Secrets ---------------------------------------------------
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
|
||||
# ---- Keycloak OAuth Clients ------------------------------------------------
|
||||
KEYCLOAK_CLIENT_ID=tos-backend
|
||||
KEYCLOAK_CLIENT_SECRET=
|
||||
NEXTAUTH_KEYCLOAK_CLIENT_ID=tos-nextauth
|
||||
NEXTAUTH_KEYCLOAK_CLIENT_SECRET=
|
||||
|
||||
# ---- Setup Token (nach Einrichtung entfernen) -------------------------------
|
||||
SETUP_TOKEN=${SETUP_TOKEN}
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}[OK] Konfigurationsdatei erstellt: docker/.env${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
start_services() {
|
||||
echo -e "${BOLD}[4/4] Dienste starten...${NC}"
|
||||
|
||||
cd "${SCRIPT_DIR}/docker"
|
||||
|
||||
if [[ "${SSL_MODE}" == "letsencrypt" ]]; then
|
||||
echo -e "${YELLOW}Starte alle Dienste mit Let's Encrypt (SSL)...${NC}"
|
||||
docker compose -f docker-compose.prod.yml --profile ssl up -d --build
|
||||
else
|
||||
echo -e "${YELLOW}Starte alle Dienste ohne SSL (externer Reverse Proxy)...${NC}"
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
echo -e "${YELLOW}Hinweis: Konfiguriere deinen Reverse Proxy fuer Ports 3000 (Web), 3001 (API) und 8080 (Keycloak).${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Warte auf API-Bereitschaft (max. 3 Minuten)...${NC}"
|
||||
API_URL="http://localhost:3001/api/v1/health/liveness"
|
||||
for i in $(seq 1 36); do
|
||||
if curl -sf "${API_URL}" > /dev/null 2>&1; then
|
||||
echo ""
|
||||
echo -e "${GREEN}[OK] API ist bereit!${NC}"
|
||||
break
|
||||
fi
|
||||
if [[ $i -eq 36 ]]; then
|
||||
echo ""
|
||||
echo -e "${RED}[WARNUNG] API hat nicht innerhalb von 3 Minuten geantwortet.${NC}"
|
||||
echo -e " Pruefe die Logs mit: docker compose -f docker/docker-compose.prod.yml logs api"
|
||||
echo -e " Die Dienste laufen moeglicherweise noch hoch. Pruefe den Status mit:"
|
||||
echo -e " docker compose -f docker/docker-compose.prod.yml ps"
|
||||
break
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 5
|
||||
done
|
||||
echo ""
|
||||
cd "${SCRIPT_DIR}"
|
||||
}
|
||||
|
||||
print_completion() {
|
||||
local SETUP_URL
|
||||
if [[ "${SSL_MODE}" == "letsencrypt" ]]; then
|
||||
SETUP_URL="https://${APP_DOMAIN}/setup?token=${SETUP_TOKEN}"
|
||||
else
|
||||
SETUP_URL="http://localhost:3000/setup?token=${SETUP_TOKEN}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}${BOLD}================================================================${NC}"
|
||||
echo -e "${GREEN}${BOLD} Infrastruktur erfolgreich gestartet!${NC}"
|
||||
echo -e "${GREEN}${BOLD}================================================================${NC}"
|
||||
echo ""
|
||||
echo -e " Fahre jetzt mit der Einrichtung im Browser fort:"
|
||||
echo ""
|
||||
echo -e " ${BOLD}${BLUE}${SETUP_URL}${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW} WICHTIG: Notiere dir diesen Setup-Token:${NC}"
|
||||
echo -e " ${BOLD}${SETUP_TOKEN}${NC}"
|
||||
echo ""
|
||||
echo -e " Der Token wird nur einmal angezeigt und ist in"
|
||||
echo -e " docker/.env gespeichert (SETUP_TOKEN=...)."
|
||||
echo ""
|
||||
echo -e " Nach der Einrichtung kannst du SETUP_TOKEN aus"
|
||||
echo -e " docker/.env entfernen."
|
||||
echo ""
|
||||
echo -e " Nuetzliche Befehle:"
|
||||
echo -e " ${BOLD}docker compose -f docker/docker-compose.prod.yml logs -f${NC} # Alle Logs"
|
||||
echo -e " ${BOLD}docker compose -f docker/docker-compose.prod.yml ps${NC} # Service Status"
|
||||
echo -e " ${BOLD}docker compose -f docker/docker-compose.prod.yml down${NC} # Stoppen"
|
||||
echo -e " ${BOLD}docker compose -f docker/docker-compose.prod.yml up -d${NC} # Starten"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main() {
|
||||
banner
|
||||
check_prerequisites
|
||||
collect_configuration
|
||||
generate_secrets
|
||||
start_services
|
||||
print_completion
|
||||
}
|
||||
|
||||
main "$@"
|
||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
||||
|
||||
apps/api:
|
||||
dependencies:
|
||||
'@nestjs/axios':
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2)
|
||||
'@nestjs/common':
|
||||
specifier: ^10.3.0
|
||||
version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -49,7 +52,7 @@ importers:
|
||||
version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)
|
||||
'@nestjs/terminus':
|
||||
specifier: ^10.2.0
|
||||
version: 10.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
version: 10.3.0(@nestjs/axios@4.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@prisma/client':
|
||||
specifier: ^5.8.0
|
||||
version: 5.22.0(prisma@5.22.0)
|
||||
@@ -1111,6 +1114,13 @@ packages:
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
'@nestjs/axios@4.0.1':
|
||||
resolution: {integrity: sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||
axios: ^1.3.1
|
||||
rxjs: ^7.0.0
|
||||
|
||||
'@nestjs/cli@10.4.9':
|
||||
resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==}
|
||||
engines: {node: '>= 16.14'}
|
||||
@@ -6948,6 +6958,12 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@nestjs/axios@4.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
axios: 1.13.4
|
||||
rxjs: 7.8.2
|
||||
|
||||
'@nestjs/cli@10.4.9':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
|
||||
@@ -7086,7 +7102,7 @@ snapshots:
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.14.3
|
||||
|
||||
'@nestjs/terminus@10.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
'@nestjs/terminus@10.3.0(@nestjs/axios@4.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -7095,6 +7111,7 @@ snapshots:
|
||||
reflect-metadata: 0.2.2
|
||||
rxjs: 7.8.2
|
||||
optionalDependencies:
|
||||
'@nestjs/axios': 4.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2)
|
||||
'@prisma/client': 5.22.0(prisma@5.22.0)
|
||||
|
||||
'@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)':
|
||||
|
||||
Reference in New Issue
Block a user