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:
WorkClub Automation
2026-03-03 14:09:25 +01:00
parent cf7b47cb69
commit ba024c45be
64 changed files with 4598 additions and 16 deletions

View 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 ✓

View 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

View 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.

View 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

View 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.

View 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.

View 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) ✓

View 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

View 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 ✓

View 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

View File

@@ -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

View File

@@ -1,6 +0,0 @@
namespace WorkClub.Domain;
public class Class1
{
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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;
}
}

View File

@@ -0,0 +1,9 @@
namespace WorkClub.Domain.Enums;
public enum ClubRole
{
Admin = 0,
Manager = 1,
Member = 2,
Viewer = 3
}

View File

@@ -0,0 +1,10 @@
namespace WorkClub.Domain.Enums;
public enum SportType
{
Tennis = 0,
Cycling = 1,
Swimming = 2,
Football = 3,
Other = 4
}

View File

@@ -0,0 +1,10 @@
namespace WorkClub.Domain.Enums;
public enum WorkItemStatus
{
Open = 0,
Assigned = 1,
InProgress = 2,
Review = 3,
Done = 4
}

View 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; }
}

View 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));
}
}

View File

@@ -1,10 +0,0 @@
namespace WorkClub.Tests.Unit;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

41
frontend/.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

23
frontend/components.json Normal file
View 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": {}
}

View 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
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;

43
frontend/package.json Normal file
View 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"
]
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View 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

View 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
View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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;
}
}

View 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
View 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>
);
}

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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
View 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"]
}

View 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

View 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

View 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;

View 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

View 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

View 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

View 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"

View 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

View 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

View 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

View 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
View 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