feat(domain): add core entities — Club, Member, WorkItem, Shift with state machine
- Create domain entities in WorkClub.Domain/Entities: Club, Member, WorkItem, Shift, ShiftSignup - Implement enums: SportType, ClubRole, WorkItemStatus - Add ITenantEntity interface for multi-tenancy support - Implement state machine validation on WorkItem with C# 14 switch expressions - Valid transitions: Open→Assigned→InProgress→Review→Done, Review→InProgress (rework) - All invalid transitions throw InvalidOperationException - TDD approach: Write tests first, 12/12 passing - Use required properties with explicit Guid/Guid? for foreign keys - DateTimeOffset for timestamps (timezone-aware, multi-tenant friendly) - RowVersion byte[] for optimistic concurrency control - No navigation properties yet (deferred to EF Core task) - No domain events or validation attributes (YAGNI for MVP)
This commit is contained in:
101
.sisyphus/evidence/task-2-summary.txt
Normal file
101
.sisyphus/evidence/task-2-summary.txt
Normal file
@@ -0,0 +1,101 @@
|
||||
TASK 2: COMPLETE ✓
|
||||
Docker Compose with PostgreSQL 16 & Keycloak 26.x
|
||||
================================================
|
||||
|
||||
Executed: 2026-03-03
|
||||
Commit: cf7b47c (infra(docker): add Docker Compose with PostgreSQL and Keycloak)
|
||||
|
||||
## DELIVERABLES CHECKLIST
|
||||
|
||||
✓ /docker-compose.yml
|
||||
- 67 lines
|
||||
- Version: 3.9
|
||||
- Services: postgres, keycloak
|
||||
- Networks: app-network
|
||||
- Volumes: postgres-data
|
||||
|
||||
✓ /infra/keycloak/realm-export.json
|
||||
- 320 lines (contains realm configuration placeholder)
|
||||
- Format: Valid JSON
|
||||
- Ready for Task 3 population
|
||||
|
||||
✓ /infra/postgres/init.sql
|
||||
- 23 lines
|
||||
- Creates: workclub (app), keycloak (Keycloak metadata)
|
||||
- Users: app/devpass, keycloak/keycloakpass
|
||||
- Mounted to PostgreSQL container for auto-init
|
||||
|
||||
✓ Evidence documentation
|
||||
- .sisyphus/evidence/task-2-config-verification.txt
|
||||
|
||||
✓ Learnings documented
|
||||
- Appended to .sisyphus/notepads/club-work-manager/learnings.md
|
||||
- 133 lines of Docker/Keycloak patterns and gotchas
|
||||
|
||||
✓ Git commit created
|
||||
- Commit: cf7b47c
|
||||
- Message: "infra(docker): add Docker Compose with PostgreSQL and Keycloak"
|
||||
- Files: 6 changed, 712 insertions
|
||||
|
||||
## CONFIGURATION SUMMARY
|
||||
|
||||
### PostgreSQL Service
|
||||
- Image: postgres:16-alpine
|
||||
- Port: 5432
|
||||
- Databases:
|
||||
* workclub (user: app/devpass) — Application data
|
||||
* keycloak (user: keycloak/keycloakpass) — Keycloak metadata
|
||||
- Healthcheck: pg_isready -U postgres
|
||||
- Volume: postgres-data:/var/lib/postgresql/data
|
||||
- Init Script: /docker-entrypoint-initdb.d/init.sql
|
||||
|
||||
### Keycloak Service
|
||||
- Image: quay.io/keycloak/keycloak:26.1
|
||||
- Port: 8080
|
||||
- Mode: start-dev --import-realm
|
||||
- Admin: admin/admin
|
||||
- Database: keycloak (PostgreSQL)
|
||||
- Realm Import: ./infra/keycloak → /opt/keycloak/data/import
|
||||
- Healthcheck: curl -sf http://localhost:8080/health/ready
|
||||
- Depends on: postgres (service_healthy)
|
||||
|
||||
### Networking
|
||||
- Bridge Network: app-network
|
||||
- Service Discovery: postgres:5432, localhost:8080 (Keycloak UI)
|
||||
- JDBC URL: jdbc:postgresql://postgres:5432/keycloak
|
||||
|
||||
## TECHNICAL NOTES
|
||||
|
||||
1. Alpine images reduce footprint (postgres:16-alpine vs full postgres:16)
|
||||
2. Separate databases for application and Keycloak prevents conflicts
|
||||
3. Health checks with appropriate startup periods (30s for Keycloak, 10s for PostgreSQL)
|
||||
4. Ordered startup: Keycloak waits for healthy PostgreSQL
|
||||
5. Development credentials hardcoded (will be externalised in production setup)
|
||||
6. Realm import mechanism allows automated realm configuration (Task 3)
|
||||
|
||||
## ENVIRONMENT CONSTRAINTS
|
||||
|
||||
- Docker Compose CLI plugin not available in this environment
|
||||
- Configuration validated via YAML structure verification
|
||||
- Full integration testing deferred to actual Docker deployment
|
||||
- All services ready for deployment via: docker compose up -d
|
||||
|
||||
## DEPENDENT TASKS
|
||||
|
||||
- Task 3: Populate realm-export.json with actual Keycloak realm configuration
|
||||
- Task 7: PostgreSQL migrations for Entity Framework Core (uses workclub database)
|
||||
- Task 22: Add backend/frontend services to docker-compose.yml
|
||||
|
||||
## VERIFICATION STATUS
|
||||
|
||||
✓ YAML Syntax: Valid (structure verified)
|
||||
✓ Service Configuration: Both postgres and keycloak properly configured
|
||||
✓ Environment Variables: All required vars present
|
||||
✓ Volumes: postgres-data volume declared, keycloak realm import mount configured
|
||||
✓ Networks: app-network bridge network declared
|
||||
✓ Healthchecks: Configured for both services with appropriate timeouts
|
||||
✓ Database Setup: init.sql creates workclub and keycloak databases with proper users
|
||||
✓ Git Commit: Created successfully
|
||||
✓ Learnings Documented: Task 2 patterns appended to notepad
|
||||
|
||||
ALL REQUIREMENTS MET ✓
|
||||
43
.sisyphus/evidence/task-3-jwt-claims.txt
Normal file
43
.sisyphus/evidence/task-3-jwt-claims.txt
Normal file
@@ -0,0 +1,43 @@
|
||||
Task 3 JWT Claims Structure
|
||||
============================
|
||||
|
||||
Expected JWT structure for admin@test.com after authentication:
|
||||
|
||||
{
|
||||
"exp": <timestamp>,
|
||||
"iat": <timestamp>,
|
||||
"auth_time": <timestamp>,
|
||||
"jti": "<uuid>",
|
||||
"iss": "http://localhost:8080/realms/workclub",
|
||||
"aud": "workclub-app",
|
||||
"sub": "<user-uuid>",
|
||||
"typ": "Bearer",
|
||||
"azp": "workclub-app",
|
||||
"session_state": "<uuid>",
|
||||
"acr": "1",
|
||||
"scope": "openid profile email",
|
||||
"sid": "<uuid>",
|
||||
"email_verified": true,
|
||||
"clubs": {
|
||||
"club-1-uuid": "admin",
|
||||
"club-2-uuid": "member"
|
||||
},
|
||||
"name": "Admin User",
|
||||
"given_name": "Admin",
|
||||
"family_name": "User",
|
||||
"email": "admin@test.com"
|
||||
}
|
||||
|
||||
CRITICAL VERIFICATION POINTS:
|
||||
|
||||
1. 'clubs' claim MUST be present
|
||||
2. 'clubs' claim MUST be JSON object (not string)
|
||||
3. Claim structure: {"<tenant-id>": "<role>"}
|
||||
4. For admin@test.com:
|
||||
- Should have 2 entries (club-1-uuid and club-2-uuid)
|
||||
- club-1-uuid value should be "admin"
|
||||
- club-2-uuid value should be "member"
|
||||
|
||||
To verify after Docker startup:
|
||||
./infra/keycloak/test-auth.sh
|
||||
cat .sisyphus/evidence/task-3-jwt-claims.txt
|
||||
15
.sisyphus/evidence/task-3-user-auth.txt
Normal file
15
.sisyphus/evidence/task-3-user-auth.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
Task 3 User Authentication Results
|
||||
===================================
|
||||
|
||||
Docker environment not available - automated test deferred.
|
||||
|
||||
CONFIGURATION VERIFIED:
|
||||
- Realm export JSON: VALID syntax
|
||||
- 5 test users configured with club memberships
|
||||
- 2 clients configured (workclub-api, workclub-app)
|
||||
- Custom protocol mapper configured for 'clubs' JWT claim
|
||||
|
||||
To run verification once Docker is available:
|
||||
./infra/keycloak/test-auth.sh
|
||||
|
||||
This script will authenticate all users and validate JWT claims.
|
||||
176
.sisyphus/evidence/task-3-verification.txt
Normal file
176
.sisyphus/evidence/task-3-verification.txt
Normal file
@@ -0,0 +1,176 @@
|
||||
Task 3: Keycloak Realm Configuration - Verification Evidence
|
||||
==============================================================
|
||||
|
||||
Date: 2026-03-03
|
||||
Task: Configure Keycloak realm with test users and club memberships
|
||||
|
||||
REALM CONFIGURATION
|
||||
-------------------
|
||||
Realm Name: workclub
|
||||
Status: enabled
|
||||
Keycloak Version: 26.0.0
|
||||
|
||||
CLIENTS CONFIGURED
|
||||
------------------
|
||||
|
||||
1. workclub-api (Backend Confidential Client)
|
||||
- Client ID: workclub-api
|
||||
- Type: confidential
|
||||
- Client Secret: dev-secret-workclub-api-change-in-production
|
||||
- Standard Flow: disabled
|
||||
- Direct Access Grants: disabled
|
||||
- Service Accounts: enabled
|
||||
- Purpose: Backend service-to-service authentication
|
||||
|
||||
2. workclub-app (Frontend Public Client)
|
||||
- Client ID: workclub-app
|
||||
- Type: public
|
||||
- Standard Flow: enabled (OAuth2 Authorization Code Flow)
|
||||
- Direct Access Grants: enabled (for dev testing with password grant)
|
||||
- PKCE: enabled (S256 challenge method)
|
||||
- Redirect URIs: http://localhost:3000/*
|
||||
- Web Origins: http://localhost:3000
|
||||
- Purpose: Frontend SPA authentication
|
||||
|
||||
PROTOCOL MAPPER CONFIGURATION
|
||||
-----------------------------
|
||||
Mapper Name: club-membership
|
||||
Type: oidc-usermodel-attribute-mapper
|
||||
User Attribute: clubs
|
||||
Token Claim Name: clubs
|
||||
JSON Type: JSON (critical - ensures claim is parsed as JSON object)
|
||||
Includes in: ID token, access token, userinfo endpoint
|
||||
|
||||
Configuration:
|
||||
- user.attribute: clubs
|
||||
- claim.name: clubs
|
||||
- jsonType.label: JSON
|
||||
- id.token.claim: true
|
||||
- access.token.claim: true
|
||||
- userinfo.token.claim: true
|
||||
- multivalued: false
|
||||
- aggregate.attrs: false
|
||||
|
||||
TEST USERS CONFIGURED
|
||||
---------------------
|
||||
|
||||
1. admin@test.com
|
||||
Password: testpass123
|
||||
Clubs: {"club-1-uuid": "admin", "club-2-uuid": "member"}
|
||||
Description: Multi-club admin with admin role in club-1, member role in club-2
|
||||
|
||||
2. manager@test.com
|
||||
Password: testpass123
|
||||
Clubs: {"club-1-uuid": "manager"}
|
||||
Description: Single club manager with manager role in club-1
|
||||
|
||||
3. member1@test.com
|
||||
Password: testpass123
|
||||
Clubs: {"club-1-uuid": "member", "club-2-uuid": "member"}
|
||||
Description: Multi-club member with member role in both clubs
|
||||
|
||||
4. member2@test.com
|
||||
Password: testpass123
|
||||
Clubs: {"club-1-uuid": "member"}
|
||||
Description: Single club member with member role in club-1
|
||||
|
||||
5. viewer@test.com
|
||||
Password: testpass123
|
||||
Clubs: {"club-1-uuid": "viewer"}
|
||||
Description: Read-only viewer with viewer role in club-1
|
||||
|
||||
All users:
|
||||
- Email verified: true
|
||||
- Enabled: true
|
||||
- Password hashed with: pbkdf2-sha512, 210000 iterations
|
||||
- No required actions (can login immediately)
|
||||
|
||||
JSON VALIDATION
|
||||
---------------
|
||||
Realm export JSON: VALID (verified with json.tool)
|
||||
File size: 8.9 KB
|
||||
Location: /Users/mastermito/Dev/opencode/infra/keycloak/realm-export.json
|
||||
|
||||
VERIFICATION PROCEDURE
|
||||
----------------------
|
||||
To verify this configuration once Docker is running:
|
||||
|
||||
1. Start Keycloak with realm import:
|
||||
docker compose up -d keycloak
|
||||
|
||||
2. Wait for health check:
|
||||
curl -sf http://localhost:8080/health/ready
|
||||
|
||||
3. Run automated test script:
|
||||
./infra/keycloak/test-auth.sh
|
||||
|
||||
The test script will:
|
||||
- Wait for Keycloak to be ready
|
||||
- Authenticate all 5 test users using password grant
|
||||
- Extract and decode JWT access tokens
|
||||
- Verify 'clubs' claim is present and correctly formatted as JSON object
|
||||
- Validate claim values match expected club memberships
|
||||
- Generate evidence files with decoded JWTs and test results
|
||||
|
||||
EXPECTED JWT STRUCTURE
|
||||
----------------------
|
||||
When admin@test.com authenticates, the JWT should contain:
|
||||
|
||||
{
|
||||
"sub": "<uuid>",
|
||||
"email": "admin@test.com",
|
||||
"email_verified": true,
|
||||
"clubs": {
|
||||
"club-1-uuid": "admin",
|
||||
"club-2-uuid": "member"
|
||||
},
|
||||
"given_name": "Admin",
|
||||
"family_name": "User",
|
||||
...
|
||||
}
|
||||
|
||||
CRITICAL: The 'clubs' claim MUST be a JSON object (not a string).
|
||||
This is controlled by the protocol mapper's jsonType.label: JSON setting.
|
||||
|
||||
DOCKER ENVIRONMENT STATUS
|
||||
--------------------------
|
||||
Docker daemon status: NOT RUNNING (Colima failed to start)
|
||||
Reason: VZ driver error on macOS
|
||||
|
||||
Manual verification steps documented above can be executed when Docker environment is available.
|
||||
The realm export JSON is complete and valid, ready for import.
|
||||
|
||||
ARCHITECTURE IMPACT
|
||||
-------------------
|
||||
This configuration is CRITICAL for multi-tenant architecture:
|
||||
|
||||
1. Backend (Finbuckle) will read 'clubs' claim to:
|
||||
- Identify which tenants (clubs) the user belongs to
|
||||
- Determine user's role within each tenant
|
||||
- Enforce tenant isolation and authorization
|
||||
|
||||
2. Frontend (NextAuth) will use 'clubs' claim to:
|
||||
- Display club switcher UI
|
||||
- Enable user to switch between clubs
|
||||
- Show appropriate UI based on role (admin vs member vs viewer)
|
||||
|
||||
3. Claim format requirements:
|
||||
- MUST be JSON object: {"<tenant-id>": "<role>"}
|
||||
- Key = Club UUID (tenant identifier)
|
||||
- Value = Role string (admin, manager, member, viewer)
|
||||
- If claim is string instead of object, entire auth pipeline breaks
|
||||
|
||||
FILES CREATED
|
||||
-------------
|
||||
- /Users/mastermito/Dev/opencode/infra/keycloak/realm-export.json (realm config)
|
||||
- /Users/mastermito/Dev/opencode/infra/keycloak/test-auth.sh (verification script)
|
||||
- /Users/mastermito/Dev/opencode/.sisyphus/evidence/task-3-user-auth.txt (placeholder)
|
||||
- /Users/mastermito/Dev/opencode/.sisyphus/evidence/task-3-jwt-claims.txt (placeholder)
|
||||
|
||||
NEXT STEPS
|
||||
----------
|
||||
Once Docker environment is running:
|
||||
1. Execute test-auth.sh to verify all users authenticate
|
||||
2. Confirm JWT 'clubs' claim is JSON object (not string)
|
||||
3. Verify claim values match expected roles for each user
|
||||
4. Save JWT samples to evidence files for documentation
|
||||
29
.sisyphus/evidence/task-4-state-machine-invalid.txt
Normal file
29
.sisyphus/evidence/task-4-state-machine-invalid.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
# Evidence: WorkItem State Machine Invalid Transitions Validation
|
||||
|
||||
All invalid transition tests verified to throw InvalidOperationException:
|
||||
|
||||
✓ Open_ToDone_Throws - Cannot skip states
|
||||
✓ Open_ToInProgress_Throws - Must assign first
|
||||
✓ Assigned_ToDone_Throws - Must go through review
|
||||
✓ InProgress_ToOpen_Throws - No backwards transition
|
||||
✓ Done_ToAnyStatus_Throws - Terminal state enforcement
|
||||
|
||||
State machine implementation correctly enforces:
|
||||
- Valid transitions: Open → Assigned → InProgress → Review → Done
|
||||
- Rework allowed: Review → InProgress
|
||||
- All invalid transitions throw InvalidOperationException
|
||||
|
||||
Implementation in WorkClub.Domain/Entities/WorkItem.cs using C# 14 switch expression:
|
||||
```csharp
|
||||
public bool CanTransitionTo(WorkItemStatus newStatus) => (Status, newStatus) switch
|
||||
{
|
||||
(WorkItemStatus.Open, WorkItemStatus.Assigned) => true,
|
||||
(WorkItemStatus.Assigned, WorkItemStatus.InProgress) => true,
|
||||
(WorkItemStatus.InProgress, WorkItemStatus.Review) => true,
|
||||
(WorkItemStatus.Review, WorkItemStatus.Done) => true,
|
||||
(WorkItemStatus.Review, WorkItemStatus.InProgress) => true,
|
||||
_ => false
|
||||
};
|
||||
```
|
||||
|
||||
All 12 test cases passed successfully.
|
||||
22
.sisyphus/evidence/task-4-state-machine-valid.txt
Normal file
22
.sisyphus/evidence/task-4-state-machine-valid.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
Bestanden WorkClub.Tests.Unit.Domain.WorkItemStatusTests.Open_ToAssigned_Succeeds [5 ms]
|
||||
Bestanden WorkClub.Tests.Unit.Domain.WorkItemStatusTests.InProgress_ToOpen_Throws [1 ms]
|
||||
Bestanden WorkClub.Tests.Unit.Domain.WorkItemStatusTests.CanTransitionTo_InvalidTransition_ReturnsFalse [< 1 ms]
|
||||
Bestanden WorkClub.Tests.Unit.Domain.WorkItemStatusTests.CanTransitionTo_ValidTransition_ReturnsTrue [< 1 ms]
|
||||
Bestanden WorkClub.Tests.Unit.Domain.WorkItemStatusTests.Assigned_ToInProgress_Succeeds [< 1 ms]
|
||||
Bestanden WorkClub.Tests.Unit.Domain.WorkItemStatusTests.Review_ToInProgress_Succeeds [< 1 ms]
|
||||
Bestanden WorkClub.Tests.Unit.Domain.WorkItemStatusTests.Assigned_ToDone_Throws [< 1 ms]
|
||||
Bestanden WorkClub.Tests.Unit.Domain.WorkItemStatusTests.InProgress_ToReview_Succeeds [< 1 ms]
|
||||
Bestanden WorkClub.Tests.Unit.Domain.WorkItemStatusTests.Open_ToInProgress_Throws [< 1 ms]
|
||||
[xUnit.net 00:00:00.13] Finished: WorkClub.Tests.Unit
|
||||
Bestanden WorkClub.Tests.Unit.Domain.WorkItemStatusTests.Done_ToAnyStatus_Throws [< 1 ms]
|
||||
Bestanden WorkClub.Tests.Unit.Domain.WorkItemStatusTests.Review_ToDone_Succeeds [< 1 ms]
|
||||
Bestanden WorkClub.Tests.Unit.Domain.WorkItemStatusTests.Open_ToDone_Throws [< 1 ms]
|
||||
|
||||
Der Testlauf war erfolgreich.
|
||||
Gesamtzahl Tests: 12
|
||||
Bestanden: 12
|
||||
Gesamtzeit: 0,4879 Sekunden
|
||||
1>Die Erstellung von Projekt "/Users/mastermito/Dev/opencode/backend/WorkClub.Tests.Unit/WorkClub.Tests.Unit.csproj" ist abgeschlossen (VSTest Ziel(e)).
|
||||
|
||||
Der Buildvorgang wurde erfolgreich ausgeführt.
|
||||
|
||||
28
.sisyphus/evidence/task-5-dev-server.txt
Normal file
28
.sisyphus/evidence/task-5-dev-server.txt
Normal file
@@ -0,0 +1,28 @@
|
||||
=== DEV SERVER VERIFICATION ===
|
||||
Date: 2026-03-03
|
||||
Task: Initialize Next.js 15 project - Dev server test
|
||||
|
||||
DEV SERVER COMMAND: bun run dev
|
||||
STARTUP STATUS: SUCCESS
|
||||
PORT: 3000
|
||||
|
||||
=== DEV SERVER OUTPUT ===
|
||||
$ next dev
|
||||
▲ Next.js 16.1.6 (Turbopack)
|
||||
- Local: http://localhost:3000
|
||||
- Network: http://192.168.241.158:3000
|
||||
|
||||
✓ Starting...
|
||||
✓ Ready in 625ms
|
||||
GET / 200 in 1187ms (compile: 1096ms, render: 91ms)
|
||||
|
||||
=== HTTP RESPONSE TEST ===
|
||||
ENDPOINT: http://localhost:3000/
|
||||
HTTP STATUS CODE: 200
|
||||
RESPONSE: Success (HTML content returned)
|
||||
|
||||
DEV SERVER VERIFICATION: PASSED
|
||||
- Server starts successfully ✓
|
||||
- Responds on port 3000 ✓
|
||||
- HTTP 200 response ✓
|
||||
- Request processing: 1187ms (acceptable for dev) ✓
|
||||
61
.sisyphus/evidence/task-5-nextjs-build.txt
Normal file
61
.sisyphus/evidence/task-5-nextjs-build.txt
Normal file
@@ -0,0 +1,61 @@
|
||||
=== NEXT.JS 15 BUILD VERIFICATION ===
|
||||
Date: 2026-03-03
|
||||
Task: Initialize Next.js 15 project with TypeScript, Tailwind CSS, shadcn/ui
|
||||
|
||||
BUILD COMMAND: bun run build
|
||||
BUILD STATUS: SUCCESS
|
||||
EXIT CODE: 0
|
||||
|
||||
=== BUILD OUTPUT ===
|
||||
$ next build
|
||||
▲ Next.js 16.1.6 (Turbopack)
|
||||
|
||||
Creating an optimized production build ...
|
||||
✓ Compiled successfully in 2.9s
|
||||
Running TypeScript ...
|
||||
Collecting page data using 11 workers ...
|
||||
Generating static pages using 11 workers (0/4) ...
|
||||
Generating static pages using 11 workers (1/4)
|
||||
Generating static pages using 11 workers (2/4)
|
||||
Generating static pages using 11 workers (3/4)
|
||||
✓ Generating static pages using 11 workers (4/4) in 240.4ms
|
||||
Finalizing page optimization ...
|
||||
|
||||
Route (app)
|
||||
┌ ○ /
|
||||
└ ○ /_not-found
|
||||
|
||||
○ (Static) prerendered as static content
|
||||
|
||||
=== STANDALONE BUILD VERIFICATION ===
|
||||
✓ .next/standalone/ directory exists
|
||||
✓ .next/standalone/server.js exists (6553 bytes)
|
||||
✓ .next/standalone/package.json exists
|
||||
✓ .next/standalone/node_modules/ directory exists
|
||||
|
||||
Configuration applied:
|
||||
- next.config.ts: output = 'standalone' ✓
|
||||
- tsconfig.json: paths aliases @/* → ./src/* ✓
|
||||
- All directory structure created: app/, components/, lib/, hooks/, types/ ✓
|
||||
|
||||
Dependencies installed:
|
||||
- Next.js 16.1.6
|
||||
- React 19.2.3
|
||||
- React DOM 19.2.3
|
||||
- TypeScript 5.9.3
|
||||
- Tailwind CSS 4.2.1
|
||||
- ESLint 9.39.3
|
||||
|
||||
shadcn/ui components installed:
|
||||
✓ button
|
||||
✓ card
|
||||
✓ badge
|
||||
✓ input
|
||||
✓ label
|
||||
✓ select
|
||||
✓ dialog
|
||||
✓ dropdown-menu
|
||||
✓ table
|
||||
✓ sonner (toast replacement)
|
||||
|
||||
BUILD VERIFICATION: PASSED
|
||||
45
.sisyphus/evidence/task-6-kustomize-base.txt
Normal file
45
.sisyphus/evidence/task-6-kustomize-base.txt
Normal file
@@ -0,0 +1,45 @@
|
||||
Task 6: Kubernetes Kustomize Base Manifests
|
||||
============================================
|
||||
|
||||
Verification Results:
|
||||
- Kustomize build: SUCCESS ✓
|
||||
- Output: 456 lines of valid YAML
|
||||
- All manifests in infra/k8s/base/ parsed correctly
|
||||
|
||||
Resource Kinds Generated:
|
||||
- ConfigMap (2: workclub-config, postgres-init)
|
||||
- Deployment (3: workclub-api, workclub-frontend, workclub-keycloak)
|
||||
- Ingress (1: workclub-ingress)
|
||||
- Service (4: workclub-api, workclub-frontend, workclub-postgres, workclub-postgres-headless, workclub-keycloak)
|
||||
- StatefulSet (1: workclub-postgres)
|
||||
|
||||
Total Resources: 11 manifests successfully merged
|
||||
|
||||
Naming Convention:
|
||||
- All resources use 'workclub-' prefix consistently
|
||||
- Service names match Deployment/StatefulSet selectors
|
||||
- Labels align across all resources (app, component tags)
|
||||
|
||||
Configuration Validation:
|
||||
- Backend health probes: /health/startup, /health/live, /health/ready ✓
|
||||
- Frontend health probes: /api/health ✓
|
||||
- PostgreSQL health check: pg_isready command ✓
|
||||
- Keycloak health probes: /health/ready, /health/live ✓
|
||||
- Ingress routing: / → frontend, /api → backend ✓
|
||||
|
||||
Environment Variables:
|
||||
- Backend: ASPNETCORE_ENVIRONMENT, ASPNETCORE_URLS (port 8080) ✓
|
||||
- Frontend: NODE_ENV, NEXT_PUBLIC_API_URL, NEXT_PUBLIC_KEYCLOAK_URL ✓
|
||||
- Keycloak: KC_DB=postgres, KC_DB_URL_HOST=workclub-postgres ✓
|
||||
- PostgreSQL: POSTGRES_DB=workclub, POSTGRES_USER=app ✓
|
||||
|
||||
Volume Configuration:
|
||||
- StatefulSet: volumeClaimTemplates with 10Gi storage ✓
|
||||
- Init scripts: ConfigMap mounted at /docker-entrypoint-initdb.d ✓
|
||||
- Headless service: clusterIP: None for DNS discovery ✓
|
||||
|
||||
Resource Limits (Placeholders for Overlay Override):
|
||||
- Requests: cpu 100m, memory 256Mi
|
||||
- Limits: cpu 500m, memory 512Mi
|
||||
|
||||
All requirements from plan lines 655-730 implemented ✓
|
||||
26
.sisyphus/evidence/task-6-resource-names.txt
Normal file
26
.sisyphus/evidence/task-6-resource-names.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
Full Resource List from kustomize build
|
||||
========================================
|
||||
|
||||
Deployments:
|
||||
- workclub-api (port 8080, dotnet-api service)
|
||||
- workclub-frontend (port 3000, nextjs service)
|
||||
- workclub-keycloak (port 8080, auth service)
|
||||
|
||||
StatefulSet:
|
||||
- workclub-postgres (port 5432, database service)
|
||||
|
||||
Services (ClusterIP):
|
||||
- workclub-api (selector: app=workclub-api)
|
||||
- workclub-frontend (selector: app=workclub-frontend)
|
||||
- workclub-keycloak (selector: app=workclub-keycloak)
|
||||
- workclub-postgres (selector: app=workclub-postgres)
|
||||
- workclub-postgres-headless (clusterIP: None, for StatefulSet DNS)
|
||||
|
||||
ConfigMaps:
|
||||
- workclub-config (application settings: log-level, cors-origins, api-base-url, etc.)
|
||||
- postgres-init (initialization script for database setup)
|
||||
|
||||
Ingress:
|
||||
- workclub-ingress (path-based routing: / → frontend, /api → backend)
|
||||
|
||||
All resources created with consistent workclub- prefix
|
||||
@@ -136,3 +136,99 @@ _Conventions, patterns, and accumulated wisdom from task execution_
|
||||
- Task 3: Populate `realm-export.json` with actual Keycloak realm configuration
|
||||
- Task 7: PostgreSQL migrations for Entity Framework Core
|
||||
- Task 22: Add backend (Api, Application, Infrastructure services) and frontend to compose file
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Domain Entities & State Machine (2026-03-03)
|
||||
|
||||
### Key Learnings
|
||||
|
||||
1. **TDD Approach Workflow**
|
||||
- Write tests FIRST (even when entities don't exist — LSP errors expected)
|
||||
- Create minimal entities to satisfy test requirements
|
||||
- All 12 tests passed on first run after implementation
|
||||
- This validates clean state machine design
|
||||
|
||||
2. **State Machine with C# 14 Switch Expressions**
|
||||
- Pattern matching for tuple of (currentStatus, newStatus) is cleaner than if-else chains
|
||||
- Expressions vs. traditional switch: more functional, concise, easier to verify all transitions
|
||||
- Chosen over Dictionary<(status, status), bool> because:
|
||||
- Easier to read and maintain
|
||||
- Compiler can verify exhaustiveness (with `_ => false` fallback)
|
||||
- No runtime lookup overhead
|
||||
- Clear inline state diagram
|
||||
|
||||
3. **Entity Design Patterns (Domain-Driven Design)**
|
||||
- All entities use `required` properties:
|
||||
- Enforces non-null values at compile time
|
||||
- Forces explicit initialization (no accidental defaults)
|
||||
- Clean validation at instantiation
|
||||
- `TenantId` is `string` (matches Finbuckle.MultiTenant.ITenantInfo.Id type)
|
||||
- Foreign keys use explicit `Guid` or `Guid?` (not navigation properties yet)
|
||||
- `RowVersion: byte[]?` for optimistic concurrency (EF Core `[Timestamp]` attribute in Task 7)
|
||||
|
||||
4. **DateTimeOffset vs DateTime**
|
||||
- Used DateTimeOffset for CreatedAt/UpdatedAt (includes timezone offset)
|
||||
- Better for multi-tenant global apps (know exact UTC moment)
|
||||
- .NET 10 standard for timestamp columns
|
||||
- Avoids timezone confusion across regions
|
||||
|
||||
5. **ITenantEntity Interface Pattern**
|
||||
- Explicit interface property (not EF shadow property) allows:
|
||||
- Easy data seeding in tests
|
||||
- LINQ queries without special knowledge
|
||||
- Clear contract in domain code
|
||||
- Marker interface only (no methods) — true DDD boundary
|
||||
|
||||
6. **Entity Lifecycle Simplicity**
|
||||
- No domain events (YAGNI for MVP)
|
||||
- No navigation properties (deferred to EF configuration in Task 7)
|
||||
- No validation attributes — EF Fluent API handles in Task 7
|
||||
- State machine is only behavior (business rule enforcement)
|
||||
|
||||
7. **Test Structure for TDD**
|
||||
- Helper factory method `CreateWorkItem()` reduces repetition
|
||||
- AAA pattern (Arrange-Act-Assert) clear in test names
|
||||
- Arrange: Create entity with minimal valid state
|
||||
- Act: Call transition method
|
||||
- Assert: Verify state changed or exception thrown
|
||||
- xUnit [Fact] attributes sufficient (no [Theory] needed for now)
|
||||
|
||||
8. **Project Structure Observations**
|
||||
- Projects in `backend/` root, not `backend/src/` (deviation from typical convention but works)
|
||||
- Subdirectories: Entities/, Enums/, Interfaces/ (clean separation)
|
||||
- Test mirrors source structure: Domain tests in dedicated folder
|
||||
- Class1.cs stub removed before implementation
|
||||
|
||||
### Files Created
|
||||
|
||||
- `WorkClub.Domain/Enums/SportType.cs` — 5 values
|
||||
- `WorkClub.Domain/Enums/ClubRole.cs` — 4 values
|
||||
- `WorkClub.Domain/Enums/WorkItemStatus.cs` — 5 values
|
||||
- `WorkClub.Domain/Interfaces/ITenantEntity.cs` — Marker interface
|
||||
- `WorkClub.Domain/Entities/Club.cs` — Basic aggregate root
|
||||
- `WorkClub.Domain/Entities/Member.cs` — User representation
|
||||
- `WorkClub.Domain/Entities/WorkItem.cs` — Task with state machine
|
||||
- `WorkClub.Domain/Entities/Shift.cs` — Volunteer shift
|
||||
- `WorkClub.Domain/Entities/ShiftSignup.cs` — Shift registration
|
||||
- `WorkClub.Tests.Unit/Domain/WorkItemStatusTests.cs` — 12 tests, all passing
|
||||
|
||||
### Build & Test Results
|
||||
|
||||
- **Tests**: 12/12 passed (100%)
|
||||
- 5 valid transition tests ✓
|
||||
- 5 invalid transition tests ✓
|
||||
- 2 CanTransitionTo() method tests ✓
|
||||
- **Build**: Release configuration successful
|
||||
- **Warnings**: Only Finbuckle version resolution (expected, no errors)
|
||||
|
||||
### Next Steps (Tasks 5-7)
|
||||
|
||||
- Task 5: Next.js frontend (parallel)
|
||||
- Task 6: Kustomize deployment (parallel)
|
||||
- Task 7: EF Core DbContext with Fluent API configuration (blocks on these entities)
|
||||
- Add [Timestamp] attribute to RowVersion properties
|
||||
- Configure ITenantEntity filtering in DbContext
|
||||
- Set up relationships between entities
|
||||
- Configure PostgreSQL xmin concurrency token
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace WorkClub.Domain;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
15
backend/WorkClub.Domain/Entities/Club.cs
Normal file
15
backend/WorkClub.Domain/Entities/Club.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using WorkClub.Domain.Enums;
|
||||
using WorkClub.Domain.Interfaces;
|
||||
|
||||
namespace WorkClub.Domain.Entities;
|
||||
|
||||
public class Club : ITenantEntity
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required SportType SportType { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public required DateTimeOffset CreatedAt { get; set; }
|
||||
public required DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
17
backend/WorkClub.Domain/Entities/Member.cs
Normal file
17
backend/WorkClub.Domain/Entities/Member.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using WorkClub.Domain.Enums;
|
||||
using WorkClub.Domain.Interfaces;
|
||||
|
||||
namespace WorkClub.Domain.Entities;
|
||||
|
||||
public class Member : ITenantEntity
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required string ExternalUserId { get; set; }
|
||||
public required string DisplayName { get; set; }
|
||||
public required string Email { get; set; }
|
||||
public required ClubRole Role { get; set; }
|
||||
public required Guid ClubId { get; set; }
|
||||
public required DateTimeOffset CreatedAt { get; set; }
|
||||
public required DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
20
backend/WorkClub.Domain/Entities/Shift.cs
Normal file
20
backend/WorkClub.Domain/Entities/Shift.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using WorkClub.Domain.Interfaces;
|
||||
|
||||
namespace WorkClub.Domain.Entities;
|
||||
|
||||
public class Shift : ITenantEntity
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public required DateTimeOffset StartTime { get; set; }
|
||||
public required DateTimeOffset EndTime { get; set; }
|
||||
public int Capacity { get; set; } = 1;
|
||||
public required Guid ClubId { get; set; }
|
||||
public required Guid CreatedById { get; set; }
|
||||
public required DateTimeOffset CreatedAt { get; set; }
|
||||
public required DateTimeOffset UpdatedAt { get; set; }
|
||||
public byte[]? RowVersion { get; set; }
|
||||
}
|
||||
12
backend/WorkClub.Domain/Entities/ShiftSignup.cs
Normal file
12
backend/WorkClub.Domain/Entities/ShiftSignup.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using WorkClub.Domain.Interfaces;
|
||||
|
||||
namespace WorkClub.Domain.Entities;
|
||||
|
||||
public class ShiftSignup : ITenantEntity
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required Guid ShiftId { get; set; }
|
||||
public required Guid MemberId { get; set; }
|
||||
public required DateTimeOffset SignedUpAt { get; set; }
|
||||
}
|
||||
41
backend/WorkClub.Domain/Entities/WorkItem.cs
Normal file
41
backend/WorkClub.Domain/Entities/WorkItem.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using WorkClub.Domain.Enums;
|
||||
using WorkClub.Domain.Interfaces;
|
||||
|
||||
namespace WorkClub.Domain.Entities;
|
||||
|
||||
public class WorkItem : ITenantEntity
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public required WorkItemStatus Status { get; set; }
|
||||
public Guid? AssigneeId { get; set; }
|
||||
public required Guid CreatedById { get; set; }
|
||||
public required Guid ClubId { get; set; }
|
||||
public DateTimeOffset? DueDate { get; set; }
|
||||
public required DateTimeOffset CreatedAt { get; set; }
|
||||
public required DateTimeOffset UpdatedAt { get; set; }
|
||||
public byte[]? RowVersion { get; set; }
|
||||
|
||||
public bool CanTransitionTo(WorkItemStatus newStatus) => (Status, newStatus) switch
|
||||
{
|
||||
(WorkItemStatus.Open, WorkItemStatus.Assigned) => true,
|
||||
(WorkItemStatus.Assigned, WorkItemStatus.InProgress) => true,
|
||||
(WorkItemStatus.InProgress, WorkItemStatus.Review) => true,
|
||||
(WorkItemStatus.Review, WorkItemStatus.Done) => true,
|
||||
(WorkItemStatus.Review, WorkItemStatus.InProgress) => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
public void TransitionTo(WorkItemStatus newStatus)
|
||||
{
|
||||
if (!CanTransitionTo(newStatus))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot transition from {Status} to {newStatus}");
|
||||
}
|
||||
|
||||
Status = newStatus;
|
||||
}
|
||||
}
|
||||
9
backend/WorkClub.Domain/Enums/ClubRole.cs
Normal file
9
backend/WorkClub.Domain/Enums/ClubRole.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace WorkClub.Domain.Enums;
|
||||
|
||||
public enum ClubRole
|
||||
{
|
||||
Admin = 0,
|
||||
Manager = 1,
|
||||
Member = 2,
|
||||
Viewer = 3
|
||||
}
|
||||
10
backend/WorkClub.Domain/Enums/SportType.cs
Normal file
10
backend/WorkClub.Domain/Enums/SportType.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace WorkClub.Domain.Enums;
|
||||
|
||||
public enum SportType
|
||||
{
|
||||
Tennis = 0,
|
||||
Cycling = 1,
|
||||
Swimming = 2,
|
||||
Football = 3,
|
||||
Other = 4
|
||||
}
|
||||
10
backend/WorkClub.Domain/Enums/WorkItemStatus.cs
Normal file
10
backend/WorkClub.Domain/Enums/WorkItemStatus.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace WorkClub.Domain.Enums;
|
||||
|
||||
public enum WorkItemStatus
|
||||
{
|
||||
Open = 0,
|
||||
Assigned = 1,
|
||||
InProgress = 2,
|
||||
Review = 3,
|
||||
Done = 4
|
||||
}
|
||||
13
backend/WorkClub.Domain/Interfaces/ITenantEntity.cs
Normal file
13
backend/WorkClub.Domain/Interfaces/ITenantEntity.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace WorkClub.Domain.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for tenant-aware entities.
|
||||
/// All domain entities must implement this to support multi-tenancy.
|
||||
/// </summary>
|
||||
public interface ITenantEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the tenant ID (Finbuckle multi-tenant identifier).
|
||||
/// </summary>
|
||||
string TenantId { get; set; }
|
||||
}
|
||||
168
backend/WorkClub.Tests.Unit/Domain/WorkItemStatusTests.cs
Normal file
168
backend/WorkClub.Tests.Unit/Domain/WorkItemStatusTests.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using WorkClub.Domain.Entities;
|
||||
using WorkClub.Domain.Enums;
|
||||
|
||||
namespace WorkClub.Tests.Unit.Domain;
|
||||
|
||||
public class WorkItemStatusTests
|
||||
{
|
||||
private static WorkItem CreateWorkItem(WorkItemStatus status = WorkItemStatus.Open)
|
||||
{
|
||||
return new WorkItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "test-tenant",
|
||||
Title = "Test Work Item",
|
||||
Status = status,
|
||||
CreatedById = Guid.NewGuid(),
|
||||
ClubId = Guid.NewGuid(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Open_ToAssigned_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var workItem = CreateWorkItem(WorkItemStatus.Open);
|
||||
|
||||
// Act
|
||||
workItem.TransitionTo(WorkItemStatus.Assigned);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(WorkItemStatus.Assigned, workItem.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assigned_ToInProgress_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var workItem = CreateWorkItem(WorkItemStatus.Assigned);
|
||||
|
||||
// Act
|
||||
workItem.TransitionTo(WorkItemStatus.InProgress);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(WorkItemStatus.InProgress, workItem.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InProgress_ToReview_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var workItem = CreateWorkItem(WorkItemStatus.InProgress);
|
||||
|
||||
// Act
|
||||
workItem.TransitionTo(WorkItemStatus.Review);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(WorkItemStatus.Review, workItem.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Review_ToDone_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var workItem = CreateWorkItem(WorkItemStatus.Review);
|
||||
|
||||
// Act
|
||||
workItem.TransitionTo(WorkItemStatus.Done);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(WorkItemStatus.Done, workItem.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Review_ToInProgress_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var workItem = CreateWorkItem(WorkItemStatus.Review);
|
||||
|
||||
// Act
|
||||
workItem.TransitionTo(WorkItemStatus.InProgress);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(WorkItemStatus.InProgress, workItem.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Open_ToDone_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var workItem = CreateWorkItem(WorkItemStatus.Open);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workItem.TransitionTo(WorkItemStatus.Done));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Open_ToInProgress_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var workItem = CreateWorkItem(WorkItemStatus.Open);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workItem.TransitionTo(WorkItemStatus.InProgress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assigned_ToDone_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var workItem = CreateWorkItem(WorkItemStatus.Assigned);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workItem.TransitionTo(WorkItemStatus.Done));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InProgress_ToOpen_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var workItem = CreateWorkItem(WorkItemStatus.InProgress);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workItem.TransitionTo(WorkItemStatus.Open));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Done_ToAnyStatus_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var workItem = CreateWorkItem(WorkItemStatus.Done);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workItem.TransitionTo(WorkItemStatus.Open));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workItem.TransitionTo(WorkItemStatus.Assigned));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workItem.TransitionTo(WorkItemStatus.InProgress));
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
workItem.TransitionTo(WorkItemStatus.Review));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanTransitionTo_ValidTransition_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var workItem = CreateWorkItem(WorkItemStatus.Open);
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(workItem.CanTransitionTo(WorkItemStatus.Assigned));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanTransitionTo_InvalidTransition_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var workItem = CreateWorkItem(WorkItemStatus.Open);
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(workItem.CanTransitionTo(WorkItemStatus.Done));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace WorkClub.Tests.Unit;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
frontend/README.md
Normal file
36
frontend/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
1570
frontend/bun.lock
Normal file
1570
frontend/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/components.json
Normal file
23
frontend/components.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
frontend/next.config.ts
Normal file
7
frontend/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
43
frontend/package.json
Normal file
43
frontend/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.576.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"ignoreScripts": [
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
],
|
||||
"trustedDependencies": [
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
frontend/public/next.svg
Normal file
1
frontend/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
126
frontend/src/app/globals.css
Normal file
126
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,126 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
34
frontend/src/app/layout.tsx
Normal file
34
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
65
frontend/src/app/page.tsx
Normal file
65
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/ui/badge.tsx
Normal file
48
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
64
frontend/src/components/ui/button.tsx
Normal file
64
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
158
frontend/src/components/ui/dialog.tsx
Normal file
158
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
257
frontend/src/components/ui/dropdown-menu.tsx
Normal file
257
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
190
frontend/src/components/ui/select.tsx
Normal file
190
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
40
frontend/src/components/ui/sonner.tsx
Normal file
40
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
116
frontend/src/components/ui/table.tsx
Normal file
116
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
34
frontend/tsconfig.json
Normal file
34
frontend/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
74
infra/k8s/base/backend-deployment.yaml
Normal file
74
infra/k8s/base/backend-deployment.yaml
Normal file
@@ -0,0 +1,74 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: workclub-api
|
||||
labels:
|
||||
app: workclub-api
|
||||
component: backend
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: workclub-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: workclub-api
|
||||
component: backend
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: workclub-api:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health/startup
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 30
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 15
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 2
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
env:
|
||||
- name: ASPNETCORE_ENVIRONMENT
|
||||
value: "Development"
|
||||
- name: ASPNETCORE_URLS
|
||||
value: "http://+:8080"
|
||||
- name: ConnectionStrings__DefaultConnection
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: workclub-secrets
|
||||
key: database-connection-string
|
||||
- name: Keycloak__Url
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: workclub-config
|
||||
key: keycloak-url
|
||||
16
infra/k8s/base/backend-service.yaml
Normal file
16
infra/k8s/base/backend-service.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: workclub-api
|
||||
labels:
|
||||
app: workclub-api
|
||||
component: backend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: workclub-api
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
41
infra/k8s/base/configmap.yaml
Normal file
41
infra/k8s/base/configmap.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: workclub-config
|
||||
labels:
|
||||
app: workclub
|
||||
data:
|
||||
log-level: "Information"
|
||||
cors-origins: "http://localhost:3000"
|
||||
api-base-url: "http://workclub-api"
|
||||
keycloak-url: "http://workclub-keycloak"
|
||||
keycloak-realm: "workclub"
|
||||
|
||||
# Database configuration
|
||||
database-host: "workclub-postgres"
|
||||
database-port: "5432"
|
||||
database-name: "workclub"
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: postgres-init
|
||||
labels:
|
||||
app: workclub-postgres
|
||||
data:
|
||||
init.sql: |
|
||||
-- Create keycloak database and user
|
||||
CREATE DATABASE keycloak;
|
||||
CREATE USER keycloak WITH PASSWORD 'keycloakpass';
|
||||
GRANT ALL PRIVILEGES ON DATABASE keycloak TO keycloak;
|
||||
|
||||
-- Keycloak database permissions
|
||||
\c keycloak
|
||||
GRANT ALL PRIVILEGES ON SCHEMA public TO keycloak;
|
||||
ALTER SCHEMA public OWNER TO keycloak;
|
||||
|
||||
-- Application database permissions
|
||||
\c workclub
|
||||
GRANT ALL PRIVILEGES ON SCHEMA public TO app;
|
||||
ALTER SCHEMA public OWNER TO app;
|
||||
64
infra/k8s/base/frontend-deployment.yaml
Normal file
64
infra/k8s/base/frontend-deployment.yaml
Normal file
@@ -0,0 +1,64 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: workclub-frontend
|
||||
labels:
|
||||
app: workclub-frontend
|
||||
component: frontend
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: workclub-frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: workclub-frontend
|
||||
component: frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: workclub-frontend:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 2
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 15
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
- name: NEXT_PUBLIC_API_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: workclub-config
|
||||
key: api-base-url
|
||||
- name: NEXT_PUBLIC_KEYCLOAK_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: workclub-config
|
||||
key: keycloak-url
|
||||
16
infra/k8s/base/frontend-service.yaml
Normal file
16
infra/k8s/base/frontend-service.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: workclub-frontend
|
||||
labels:
|
||||
app: workclub-frontend
|
||||
component: frontend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: workclub-frontend
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
25
infra/k8s/base/ingress.yaml
Normal file
25
infra/k8s/base/ingress.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: workclub-ingress
|
||||
labels:
|
||||
app: workclub
|
||||
spec:
|
||||
rules:
|
||||
- host: localhost
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: workclub-frontend
|
||||
port:
|
||||
number: 80
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: workclub-api
|
||||
port:
|
||||
number: 80
|
||||
81
infra/k8s/base/keycloak-deployment.yaml
Normal file
81
infra/k8s/base/keycloak-deployment.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: workclub-keycloak
|
||||
labels:
|
||||
app: workclub-keycloak
|
||||
component: auth
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: workclub-keycloak
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: workclub-keycloak
|
||||
component: auth
|
||||
spec:
|
||||
containers:
|
||||
- name: keycloak
|
||||
image: quay.io/keycloak/keycloak:26.1
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- start
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 2
|
||||
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: http
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 15
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
env:
|
||||
- name: KC_DB
|
||||
value: postgres
|
||||
- name: KC_DB_URL_HOST
|
||||
value: workclub-postgres
|
||||
- name: KC_DB_URL_PORT
|
||||
value: "5432"
|
||||
- name: KC_DB_URL_DATABASE
|
||||
value: keycloak
|
||||
- name: KC_DB_USERNAME
|
||||
value: keycloak
|
||||
- name: KC_DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: workclub-secrets
|
||||
key: keycloak-db-password
|
||||
- name: KEYCLOAK_ADMIN
|
||||
value: admin
|
||||
- name: KEYCLOAK_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: workclub-secrets
|
||||
key: keycloak-admin-password
|
||||
- name: KC_HOSTNAME_STRICT
|
||||
value: "false"
|
||||
- name: KC_PROXY
|
||||
value: "edge"
|
||||
- name: KC_HTTP_ENABLED
|
||||
value: "true"
|
||||
16
infra/k8s/base/keycloak-service.yaml
Normal file
16
infra/k8s/base/keycloak-service.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: workclub-keycloak
|
||||
labels:
|
||||
app: workclub-keycloak
|
||||
component: auth
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: workclub-keycloak
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
14
infra/k8s/base/kustomization.yaml
Normal file
14
infra/k8s/base/kustomization.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- backend-deployment.yaml
|
||||
- backend-service.yaml
|
||||
- frontend-deployment.yaml
|
||||
- frontend-service.yaml
|
||||
- postgres-statefulset.yaml
|
||||
- postgres-service.yaml
|
||||
- keycloak-deployment.yaml
|
||||
- keycloak-service.yaml
|
||||
- configmap.yaml
|
||||
- ingress.yaml
|
||||
37
infra/k8s/base/postgres-service.yaml
Normal file
37
infra/k8s/base/postgres-service.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: workclub-postgres-headless
|
||||
labels:
|
||||
app: workclub-postgres
|
||||
component: database
|
||||
spec:
|
||||
type: ClusterIP
|
||||
clusterIP: None
|
||||
selector:
|
||||
app: workclub-postgres
|
||||
ports:
|
||||
- name: postgresql
|
||||
port: 5432
|
||||
targetPort: 5432
|
||||
protocol: TCP
|
||||
publishNotReadyAddresses: true
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: workclub-postgres
|
||||
labels:
|
||||
app: workclub-postgres
|
||||
component: database
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: workclub-postgres
|
||||
ports:
|
||||
- name: postgresql
|
||||
port: 5432
|
||||
targetPort: 5432
|
||||
protocol: TCP
|
||||
91
infra/k8s/base/postgres-statefulset.yaml
Normal file
91
infra/k8s/base/postgres-statefulset.yaml
Normal file
@@ -0,0 +1,91 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: workclub-postgres
|
||||
labels:
|
||||
app: workclub-postgres
|
||||
component: database
|
||||
spec:
|
||||
serviceName: workclub-postgres-headless
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: workclub-postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: workclub-postgres
|
||||
component: database
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: postgresql
|
||||
containerPort: 5432
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- pg_isready -U app -d workclub
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 15
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- pg_isready -U app -d workclub
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 2
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
value: workclub
|
||||
- name: POSTGRES_USER
|
||||
value: app
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: workclub-secrets
|
||||
key: postgres-password
|
||||
volumeMounts:
|
||||
- name: postgres-data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
subPath: postgres
|
||||
- name: postgres-init
|
||||
mountPath: /docker-entrypoint-initdb.d
|
||||
|
||||
volumes:
|
||||
- name: postgres-init
|
||||
configMap:
|
||||
name: postgres-init
|
||||
items:
|
||||
- key: init.sql
|
||||
path: init.sql
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: postgres-data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: standard
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
141
infra/keycloak/test-auth.sh
Executable file
141
infra/keycloak/test-auth.sh
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Test script for Keycloak authentication and JWT claims verification
|
||||
# This script validates the realm configuration after import
|
||||
|
||||
KEYCLOAK_URL="${KEYCLOAK_URL:-http://localhost:8080}"
|
||||
REALM="workclub"
|
||||
CLIENT_ID="workclub-app"
|
||||
|
||||
# Color output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo "=== Keycloak Authentication Test ==="
|
||||
echo "Keycloak URL: $KEYCLOAK_URL"
|
||||
echo "Realm: $REALM"
|
||||
echo ""
|
||||
|
||||
# Wait for Keycloak to be ready
|
||||
echo "Waiting for Keycloak to be ready..."
|
||||
max_attempts=60
|
||||
attempt=0
|
||||
while ! curl -sf "$KEYCLOAK_URL/health/ready" > /dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ $attempt -ge $max_attempts ]; then
|
||||
echo -e "${RED}✗ Keycloak failed to become ready after $max_attempts attempts${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
done
|
||||
echo -e "\n${GREEN}✓ Keycloak is ready${NC}\n"
|
||||
|
||||
# Test users with expected club memberships
|
||||
declare -A USERS=(
|
||||
["admin@test.com"]='{"club-1-uuid":"admin","club-2-uuid":"member"}'
|
||||
["manager@test.com"]='{"club-1-uuid":"manager"}'
|
||||
["member1@test.com"]='{"club-1-uuid":"member","club-2-uuid":"member"}'
|
||||
["member2@test.com"]='{"club-1-uuid":"member"}'
|
||||
["viewer@test.com"]='{"club-1-uuid":"viewer"}'
|
||||
)
|
||||
|
||||
PASSWORD="testpass123"
|
||||
EVIDENCE_DIR=".sisyphus/evidence"
|
||||
mkdir -p "$EVIDENCE_DIR"
|
||||
|
||||
RESULTS_FILE="$EVIDENCE_DIR/task-3-user-auth.txt"
|
||||
JWT_FILE="$EVIDENCE_DIR/task-3-jwt-claims.txt"
|
||||
|
||||
# Clear previous results
|
||||
> "$RESULTS_FILE"
|
||||
> "$JWT_FILE"
|
||||
|
||||
echo "Testing authentication for all users..." | tee -a "$RESULTS_FILE"
|
||||
echo "=======================================" | tee -a "$RESULTS_FILE"
|
||||
echo "" | tee -a "$RESULTS_FILE"
|
||||
|
||||
success_count=0
|
||||
failure_count=0
|
||||
|
||||
for user in "${!USERS[@]}"; do
|
||||
expected_clubs="${USERS[$user]}"
|
||||
|
||||
echo "Testing: $user" | tee -a "$RESULTS_FILE"
|
||||
echo "Expected clubs: $expected_clubs" | tee -a "$RESULTS_FILE"
|
||||
|
||||
# Request token using direct grant (password grant)
|
||||
response=$(curl -s -X POST "$KEYCLOAK_URL/realms/$REALM/protocol/openid-connect/token" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=password" \
|
||||
-d "client_id=$CLIENT_ID" \
|
||||
-d "username=$user" \
|
||||
-d "password=$PASSWORD" \
|
||||
2>&1)
|
||||
|
||||
# Check if token was obtained
|
||||
if echo "$response" | jq -e '.access_token' > /dev/null 2>&1; then
|
||||
access_token=$(echo "$response" | jq -r '.access_token')
|
||||
|
||||
# Decode JWT (extract payload, base64 decode)
|
||||
payload=$(echo "$access_token" | cut -d. -f2)
|
||||
# Add padding if needed for base64
|
||||
padding=$((4 - ${#payload} % 4))
|
||||
if [ $padding -ne 4 ]; then
|
||||
payload="${payload}$(printf '=%.0s' $(seq 1 $padding))"
|
||||
fi
|
||||
|
||||
decoded=$(echo "$payload" | base64 -d 2>/dev/null | jq '.')
|
||||
|
||||
# Extract clubs claim
|
||||
clubs_claim=$(echo "$decoded" | jq -c '.clubs // empty')
|
||||
|
||||
if [ -z "$clubs_claim" ]; then
|
||||
echo -e " ${RED}✗ FAILED: No 'clubs' claim found in JWT${NC}" | tee -a "$RESULTS_FILE"
|
||||
failure_count=$((failure_count + 1))
|
||||
elif [ "$clubs_claim" == "$expected_clubs" ]; then
|
||||
echo -e " ${GREEN}✓ SUCCESS: Clubs claim matches expected value${NC}" | tee -a "$RESULTS_FILE"
|
||||
success_count=$((success_count + 1))
|
||||
|
||||
# Save decoded JWT for first successful user (admin)
|
||||
if [ "$user" == "admin@test.com" ]; then
|
||||
echo "=== Decoded JWT for admin@test.com ===" > "$JWT_FILE"
|
||||
echo "$decoded" | jq '.' >> "$JWT_FILE"
|
||||
echo "" >> "$JWT_FILE"
|
||||
echo "=== Clubs Claim ===" >> "$JWT_FILE"
|
||||
echo "$clubs_claim" | jq '.' >> "$JWT_FILE"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}✗ FAILED: Clubs claim mismatch${NC}" | tee -a "$RESULTS_FILE"
|
||||
echo " Expected: $expected_clubs" | tee -a "$RESULTS_FILE"
|
||||
echo " Got: $clubs_claim" | tee -a "$RESULTS_FILE"
|
||||
failure_count=$((failure_count + 1))
|
||||
fi
|
||||
|
||||
echo " Claim type: $(echo "$clubs_claim" | jq -r 'type')" | tee -a "$RESULTS_FILE"
|
||||
else
|
||||
echo -e " ${RED}✗ FAILED: Could not obtain access token${NC}" | tee -a "$RESULTS_FILE"
|
||||
echo " Error: $(echo "$response" | jq -r '.error_description // .error // "Unknown error"')" | tee -a "$RESULTS_FILE"
|
||||
failure_count=$((failure_count + 1))
|
||||
fi
|
||||
|
||||
echo "" | tee -a "$RESULTS_FILE"
|
||||
done
|
||||
|
||||
echo "=======================================" | tee -a "$RESULTS_FILE"
|
||||
echo "Summary: $success_count passed, $failure_count failed" | tee -a "$RESULTS_FILE"
|
||||
echo "" | tee -a "$RESULTS_FILE"
|
||||
|
||||
if [ $failure_count -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ All authentication tests passed!${NC}" | tee -a "$RESULTS_FILE"
|
||||
echo "Evidence saved to:" | tee -a "$RESULTS_FILE"
|
||||
echo " - $RESULTS_FILE" | tee -a "$RESULTS_FILE"
|
||||
echo " - $JWT_FILE" | tee -a "$RESULTS_FILE"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}✗ Some tests failed${NC}" | tee -a "$RESULTS_FILE"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user