commit
This commit is contained in:
316
API_DOCUMENTATION.md
Normal file
316
API_DOCUMENTATION.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# Elysia API Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses ElysiaJS integrated with Next.js App Router to create high-performance, type-safe APIs. The codebase follows a **Feature-based MVC pattern** for maintainability and scalability.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
http://localhost:3001
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Customers API
|
||||
|
||||
#### Get All Customers by Branch
|
||||
|
||||
```
|
||||
GET /api/customers/:branch
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `branch` (path parameter, required): Branch identifier
|
||||
- Examples: `branch-01`, `branch-02`, `head-office`
|
||||
- `status` (query parameter, optional): Filter by customer status
|
||||
- Values: `active`, `inactive`, `pending`
|
||||
|
||||
**Examples:**
|
||||
|
||||
1. Get all customers from branch-01:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/customers/branch-01
|
||||
```
|
||||
|
||||
2. Get active customers from branch-02:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3001/api/customers/branch-02?status=active"
|
||||
```
|
||||
|
||||
3. Get pending customers from head-office:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3001/api/customers/head-office?status=pending"
|
||||
```
|
||||
|
||||
**Response Format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "cust-001",
|
||||
"branch": "branch-01",
|
||||
"name": "สมชาย ใจดี",
|
||||
"email": "somchai@example.com",
|
||||
"phone": "081-234-5678",
|
||||
"company": "บริษัท ไทยธุรกิจ จำกัด",
|
||||
"address": "123 ถนนสุขุมวิท แขวงคลองตัน เขตคลองเตย กรุงเทพฯ 10110",
|
||||
"status": "active",
|
||||
"createdAt": "2024-01-15T09:00:00Z",
|
||||
"updatedAt": "2024-01-15T09:00:00Z"
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
"message": "Found 1 customer(s) for branch: branch-01"
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Single Customer by ID
|
||||
|
||||
```
|
||||
GET /api/customers/:branch/:id
|
||||
```
|
||||
|
||||
#### Create Customer
|
||||
|
||||
```
|
||||
POST /api/customers
|
||||
```
|
||||
|
||||
#### Update Customer
|
||||
|
||||
```
|
||||
PUT /api/customers/:branch/:id
|
||||
```
|
||||
|
||||
#### Delete Customer
|
||||
|
||||
```
|
||||
DELETE /api/customers/:branch/:id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Quotations API
|
||||
|
||||
#### Get All Quotations by Branch
|
||||
|
||||
```
|
||||
GET /api/quotations/:branch
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `branch` (path parameter, required): Branch identifier
|
||||
- Examples: `branch-01`, `branch-02`, `head-office`
|
||||
- `status` (query parameter, optional): Filter by quotation status
|
||||
- Values: `draft`, `sent`, `accepted`, `rejected`, `expired`
|
||||
|
||||
**Examples:**
|
||||
|
||||
1. Get all quotations from branch-01:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/quotations/branch-01
|
||||
```
|
||||
|
||||
2. Get sent quotations from head-office:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3001/api/quotations/head-office?status=sent"
|
||||
```
|
||||
|
||||
**Response Format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "quot-001",
|
||||
"quotationNumber": "QT-2024-001",
|
||||
"branch": "branch-01",
|
||||
"customerId": "cust-001",
|
||||
"customerName": "สมชาย ใจดี",
|
||||
"date": "2024-01-20T00:00:00Z",
|
||||
"validUntil": "2024-02-20T00:00:00Z",
|
||||
"subtotal": 50000,
|
||||
"taxRate": 0.07,
|
||||
"taxAmount": 3500,
|
||||
"totalAmount": 53500,
|
||||
"status": "sent",
|
||||
"notes": "Quotation for office supplies",
|
||||
"createdAt": "2024-01-20T09:00:00Z",
|
||||
"updatedAt": "2024-01-20T09:00:00Z"
|
||||
}
|
||||
],
|
||||
"count": 2,
|
||||
"message": "Found 2 quotation(s) for branch: branch-01"
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Single Quotation by ID
|
||||
|
||||
```
|
||||
GET /api/quotations/:branch/:id
|
||||
```
|
||||
|
||||
#### Create Quotation
|
||||
|
||||
```
|
||||
POST /api/quotations
|
||||
```
|
||||
|
||||
#### Update Quotation
|
||||
|
||||
```
|
||||
PUT /api/quotations/:branch/:id
|
||||
```
|
||||
|
||||
#### Delete Quotation
|
||||
|
||||
```
|
||||
DELETE /api/quotations/:branch/:id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available Data
|
||||
|
||||
### Customers
|
||||
|
||||
- `branch-01`: 4 customers (3 active, 1 pending)
|
||||
- `branch-02`: 3 customers (1 active, 1 inactive, 1 pending)
|
||||
- `head-office`: 3 customers (all active)
|
||||
|
||||
### Quotations
|
||||
|
||||
- `branch-01`: 2 quotations (1 sent, 1 accepted)
|
||||
- `branch-02`: 1 quotation (draft)
|
||||
- `head-office`: 1 quotation (sent)
|
||||
|
||||
## Testing with Browser
|
||||
|
||||
Simply open these URLs in your browser:
|
||||
|
||||
### Customers
|
||||
|
||||
- http://localhost:3001/api/customers/branch-01
|
||||
- http://localhost:3001/api/customers/branch-02?status=active
|
||||
- http://localhost:3001/api/customers/head-office
|
||||
|
||||
### Quotations
|
||||
|
||||
- http://localhost:3001/api/quotations/branch-01
|
||||
- http://localhost:3001/api/quotations/head-office?status=sent
|
||||
|
||||
## Project Structure
|
||||
|
||||
This project follows the **Feature-based MVC pattern** as recommended by ElysiaJS:
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ └── api/
|
||||
│ └── [[...slugs]]/
|
||||
│ └── route.ts # Main API entry point
|
||||
├── modules/
|
||||
│ ├── customers/
|
||||
│ │ ├── controller.ts # HTTP handlers & routing
|
||||
│ │ ├── service.ts # Business logic
|
||||
│ │ └── model.ts # Schemas & validation
|
||||
│ └── quotations/
|
||||
│ ├── controller.ts # HTTP handlers & routing
|
||||
│ ├── service.ts # Business logic
|
||||
│ └── model.ts # Schemas & validation
|
||||
├── types/
|
||||
│ └── customer.ts # Shared types
|
||||
├── lib/
|
||||
│ └── mock-data.ts # Mock data
|
||||
```
|
||||
|
||||
### File Responsibilities
|
||||
|
||||
#### Model (`model.ts`)
|
||||
|
||||
- Define TypeBox schemas for validation
|
||||
- Export TypeScript types from schemas
|
||||
- All data structure definitions
|
||||
|
||||
#### Service (`service.ts`)
|
||||
|
||||
- Business logic and data operations
|
||||
- Pure functions (no Elysia dependencies)
|
||||
- CRUD operations
|
||||
- Data transformation
|
||||
|
||||
#### Controller (`controller.ts`)
|
||||
|
||||
- Elysia instance for the module
|
||||
- Route definitions and handlers
|
||||
- Request/response validation
|
||||
- Calls service functions
|
||||
- HTTP-specific concerns
|
||||
|
||||
#### Main Route (`app/api/[[...slugs]]/route.ts`)
|
||||
|
||||
- Import all controllers
|
||||
- Combine with `.use()`
|
||||
- Export handlers for Next.js
|
||||
|
||||
### Important Implementation Notes
|
||||
|
||||
This project follows the **correct ElysiaJS + Next.js integration pattern**:
|
||||
|
||||
- ✅ Single route file `[[...slugs]]/route.ts` with Elysia internal routing
|
||||
- ✅ Uses `export const GET = app.fetch` (not `.handle`)
|
||||
- ✅ Elysia instance has `prefix: '/api'`
|
||||
- ✅ All routes defined within Elysia instances using `.get()`, `.post()`, etc.
|
||||
- ✅ WinterCG compliant - works as normal Next.js API route
|
||||
- ✅ Feature-based MVC pattern for maintainability
|
||||
- ✅ Clear separation of concerns between Model, View, and Controller
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- **ElysiaJS**: Type-safe, high-performance web framework
|
||||
- **Next.js 16**: React framework with App Router
|
||||
- **TypeScript**: Type safety throughout
|
||||
- **TypeBox**: Schema validation (via `@elysiajs/schema`)
|
||||
|
||||
## Features
|
||||
|
||||
✅ Feature-based MVC architecture
|
||||
✅ Dynamic branch parameter support
|
||||
✅ Type-safe request/response validation
|
||||
✅ Optional query parameter filtering
|
||||
✅ Mock data for customers and quotations
|
||||
✅ Full TypeScript support
|
||||
✅ Auto-generated API documentation (Swagger/OpenAPI ready)
|
||||
✅ Correct ElysiaJS + Next.js integration pattern
|
||||
✅ Scalable and maintainable code structure
|
||||
✅ Clear separation of concerns
|
||||
|
||||
## Adding New Modules
|
||||
|
||||
To add a new module (e.g., `products`):
|
||||
|
||||
1. Create folder: `src/modules/products/`
|
||||
2. Create `model.ts` - Define schemas
|
||||
3. Create `service.ts` - Business logic
|
||||
4. Create `controller.ts` - Routes and handlers
|
||||
5. Update `src/app/api/[[...slugs]]/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { products } from "@/modules/products/controller";
|
||||
|
||||
const app = new Elysia({ prefix: "/api" })
|
||||
.use(customers)
|
||||
.use(quotations)
|
||||
.use(products); // Add new module
|
||||
```
|
||||
422
KEYCLOAK_AUTH.md
Normal file
422
KEYCLOAK_AUTH.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# Keycloak Authentication Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the Keycloak (OIDC) authentication implementation integrated into the Next.js + ElysiaJS application.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
```
|
||||
┌─────────────┐ 1. Init ┌──────────────┐ 2. Login ┌──────────┐
|
||||
│ Browser │ ──────────────> │ Keycloak │ ──────────────> │ Browser │
|
||||
│ │ │ Server │ │ │
|
||||
└─────────────┘ └──────────────┘ └──────────┘
|
||||
│ │
|
||||
│ 3. Token (JWT) │
|
||||
├──────────────────────────────────────────────────────────────>│
|
||||
│ │
|
||||
│ 4. API Call with Bearer Token │
|
||||
├─────────────────────────────────────────────┐ │
|
||||
│ │ │
|
||||
▼ ▼ │
|
||||
┌─────────────┐ 5. Verify Token ┌──────────────┐ │
|
||||
│ Next.js API │ ───────────────────> │ Database │ │
|
||||
│ (Elysia) │ │ (PostgreSQL) │ │
|
||||
└─────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
│ 6. User Context │
|
||||
├──────────────────────────────────────────────────────────────────>│
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### Backend (ElysiaJS)
|
||||
|
||||
#### 1. Database Layer (`src/database/`)
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/database/schema/users.ts` - User table schema
|
||||
- `src/database/db.ts` - Database connection using Drizzle ORM
|
||||
- `drizzle.config.ts` - Drizzle configuration
|
||||
|
||||
**User Schema:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: uuid (primary key)
|
||||
keycloakId: text (unique, from Keycloak sub)
|
||||
email: text
|
||||
name: text
|
||||
createdAt: timestamp
|
||||
updatedAt: timestamp
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Keycloak Verification (`src/lib/keycloak.ts`)
|
||||
|
||||
**Functions:**
|
||||
|
||||
- `verifyToken(token)` - Verifies JWT using JWKS from Keycloak
|
||||
- `extractToken(authHeader)` - Extracts Bearer token from Authorization header
|
||||
|
||||
**Features:**
|
||||
|
||||
- Automatic JWKS caching
|
||||
- Token validation (issuer, audience, expiration)
|
||||
- Type-safe token payload
|
||||
|
||||
#### 3. Auth Middleware (`src/middleware/auth.ts`)
|
||||
|
||||
**Exports:**
|
||||
|
||||
- `authPlugin` - Elysia plugin that validates tokens and attaches user to context
|
||||
- `requireAuth` - Helper function to require authentication
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
import { authPlugin } from "@/middleware/auth";
|
||||
|
||||
// Apply to all routes
|
||||
const app = new Elysia().use(authPlugin).get("/protected", ({ user }) => {
|
||||
return { message: "Hello!", user };
|
||||
});
|
||||
```
|
||||
|
||||
#### 4. User Service (`src/modules/auth/service.ts`)
|
||||
|
||||
**Functions:**
|
||||
|
||||
- `findOrCreateUser(payload)` - Finds existing user or creates new one from Keycloak payload
|
||||
- `getUserByKeycloakId(keycloakId)` - Retrieves user by Keycloak ID
|
||||
|
||||
### Frontend (Next.js)
|
||||
|
||||
#### 1. Keycloak Client (`src/lib/keycloak-client.ts`)
|
||||
|
||||
**Functions:**
|
||||
|
||||
- `initKeycloak()` - Initializes Keycloak with `login-required` mode
|
||||
- `logout()` - Logs out user and clears tokens
|
||||
- `getUserInfo()` - Returns parsed token payload
|
||||
- `getToken()` - Returns current access token
|
||||
- `isAuthenticated()` - Check if user is authenticated
|
||||
|
||||
**Features:**
|
||||
|
||||
- Memory-only token storage (no localStorage)
|
||||
- Automatic token refresh (30 seconds before expiry)
|
||||
- Token refresh on 401 errors
|
||||
- PKCE flow for security
|
||||
|
||||
#### 2. Auth Provider (`src/providers/AuthProvider.tsx`)
|
||||
|
||||
**Context:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
userInfo: any;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**Hook:**
|
||||
|
||||
- `useAuth()` - Access auth context in components
|
||||
|
||||
#### 3. API Client (`src/lib/api-client.ts`)
|
||||
|
||||
**Enhanced Features:**
|
||||
|
||||
- Automatically adds `Authorization: Bearer <token>` header
|
||||
- Handles 401 errors by triggering token refresh
|
||||
- Reads token from `window.__KEYCLOAK_TOKEN__`
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Database Setup
|
||||
|
||||
```bash
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env with your database credentials
|
||||
# DATABASE_URL=postgresql://user:password@localhost:5432/allaos
|
||||
|
||||
# Generate and run migration
|
||||
npx drizzle-kit generate
|
||||
npx drizzle-kit migrate
|
||||
```
|
||||
|
||||
### 2. Keycloak Setup
|
||||
|
||||
#### Create a Realm and Client:
|
||||
|
||||
1. Log in to Keycloak Admin Console
|
||||
2. Create a new realm (e.g., `allaos`)
|
||||
3. Create a new OpenID Connect client:
|
||||
- Client ID: `allaos-frontend`
|
||||
- Client Authentication: `ON` (for backend)
|
||||
- Valid Redirect URIs: `http://localhost:3000/*`
|
||||
- Web Origins: `http://localhost:3000`
|
||||
- Access Type: `confidential`
|
||||
|
||||
#### Configure Environment Variables:
|
||||
|
||||
```env
|
||||
# Backend (.env)
|
||||
KEYCLOAK_URL=http://localhost:8080
|
||||
KEYCLOAK_REALM=allaos
|
||||
KEYCLOAK_CLIENT_ID=allaos-frontend
|
||||
KEYCLOAK_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# Frontend (.env.local or NEXT_PUBLIC_ in .env)
|
||||
NEXT_PUBLIC_KEYCLOAK_URL=http://localhost:8080
|
||||
NEXT_PUBLIC_KEYCLOAK_REALM=allaos
|
||||
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=allaos-frontend
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install keycloak jose
|
||||
npm install -D @types/keycloak-js
|
||||
```
|
||||
|
||||
### 4. Run Application
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Protecting API Routes
|
||||
|
||||
```typescript
|
||||
import { Elysia } from "elysia";
|
||||
import { authPlugin } from "@/middleware/auth";
|
||||
|
||||
const app = new Elysia({ prefix: "/api" })
|
||||
.use(authPlugin)
|
||||
.get("/protected", ({ user, tokenPayload }) => {
|
||||
// user is now available from database
|
||||
// tokenPayload contains Keycloak claims
|
||||
return {
|
||||
message: "Protected data",
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### Accessing User Info in Components
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useAuth } from "@/providers/AuthProvider";
|
||||
|
||||
export default function UserProfile() {
|
||||
const { isAuthenticated, isLoading, userInfo, logout } = useAuth();
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (!isAuthenticated) return <div>Not authenticated</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Welcome, {userInfo?.name}</h1>
|
||||
<p>Email: {userInfo?.email}</p>
|
||||
<button onClick={logout}>Logout</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Making Authenticated API Calls
|
||||
|
||||
```typescript
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
|
||||
// Automatically includes Bearer token
|
||||
const data = await apiClient("/api/protected-endpoint");
|
||||
```
|
||||
|
||||
## Token Flow
|
||||
|
||||
### 1. Initialization
|
||||
|
||||
1. User visits application
|
||||
2. `AuthProvider` initializes Keycloak
|
||||
3. Keycloak redirects to login page (if not authenticated)
|
||||
4. User logs in
|
||||
5. Keycloak redirects back with code
|
||||
6. Keycloak exchanges code for tokens
|
||||
7. Access token stored in memory (`window.__KEYCLOAK_TOKEN__`)
|
||||
|
||||
### 2. API Calls
|
||||
|
||||
1. Component calls `apiClient()`
|
||||
2. API client reads token from `window.__KEYCLOAK_TOKEN__`
|
||||
3. Adds `Authorization: Bearer <token>` header
|
||||
4. Backend receives request, extracts token
|
||||
5. Verifies token using JWKS
|
||||
6. Finds/creates user in database
|
||||
7. Attaches user to request context
|
||||
8. Route handler processes request
|
||||
|
||||
### 3. Token Refresh
|
||||
|
||||
1. Background interval checks token every second
|
||||
2. If token expires in < 30 seconds, refresh automatically
|
||||
3. If refresh fails, redirect to login
|
||||
4. On 401 error, trigger immediate refresh attempt
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### ✅ Implemented
|
||||
|
||||
- **Memory-only token storage** - No localStorage/sessionStorage
|
||||
- **PKCE flow** - Prevents authorization code interception
|
||||
- **JWT verification** - Using JWKS from Keycloak
|
||||
- **Token expiration** - Automatic refresh before expiry
|
||||
- **HTTPS ready** - Works with secure cookies and headers
|
||||
- **CORS configured** - Only allowed origins
|
||||
|
||||
### ⚠️ Additional Recommendations
|
||||
|
||||
1. **Enable HTTPS in production**
|
||||
2. **Set up Keycloak SSL**
|
||||
3. **Implement rate limiting** on auth endpoints
|
||||
4. **Add session timeout** on client side
|
||||
5. **Implement CSRF protection** for state-changing operations
|
||||
6. **Add audit logging** for authentication events
|
||||
7. **Enable Keycloak events** for security monitoring
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. Start Keycloak and your application
|
||||
2. Visit `http://localhost:3000`
|
||||
3. You should be redirected to Keycloak login
|
||||
4. Login with test credentials
|
||||
5. After login, you should see the application
|
||||
6. Open browser DevTools → Network
|
||||
7. Check that API calls have `Authorization: Bearer <token>` header
|
||||
|
||||
### Testing Token Expiry
|
||||
|
||||
1. Set Keycloak token expiry to 1 minute (for testing)
|
||||
2. Login to application
|
||||
3. Wait for token to expire
|
||||
4. Try making an API call
|
||||
5. Token should refresh automatically
|
||||
6. If refresh fails, should redirect to login
|
||||
|
||||
### Testing Invalid Token
|
||||
|
||||
1. Manually modify `window.__KEYCLOAK_TOKEN__` in DevTools
|
||||
2. Make an API call
|
||||
3. Should receive 401 error
|
||||
4. Should trigger token refresh
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "Unauthorized: Invalid or expired token"
|
||||
|
||||
**Possible causes:**
|
||||
|
||||
- Token expired and refresh failed
|
||||
- Keycloak URL/realm/client ID mismatch
|
||||
- JWKS endpoint unreachable
|
||||
|
||||
**Solutions:**
|
||||
|
||||
- Check environment variables
|
||||
- Verify Keycloak is running
|
||||
- Check browser console for errors
|
||||
- Verify JWKS endpoint is accessible
|
||||
|
||||
### Issue: User not created in database
|
||||
|
||||
**Possible causes:**
|
||||
|
||||
- Database connection failed
|
||||
- Migration not run
|
||||
- Database permissions issue
|
||||
|
||||
**Solutions:**
|
||||
|
||||
- Run `npx drizzle-kit migrate`
|
||||
- Check `DATABASE_URL` in .env
|
||||
- Verify database is accessible
|
||||
|
||||
### Issue: Redirect loop
|
||||
|
||||
**Possible causes:**
|
||||
|
||||
- Keycloak callback URL not configured
|
||||
- Client not created or disabled
|
||||
- Invalid redirect URI
|
||||
|
||||
**Solutions:**
|
||||
|
||||
- Check Keycloak client settings
|
||||
- Verify Valid Redirect URIs
|
||||
- Check client is enabled
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── database/
|
||||
│ ├── db.ts # Database connection
|
||||
│ └── schema/
|
||||
│ ├── users.ts # User schema
|
||||
│ └── index.ts # Schema exports
|
||||
├── lib/
|
||||
│ ├── keycloak.ts # JWT verification
|
||||
│ ├── keycloak-client.ts # Keycloak JS client
|
||||
│ └── api-client.ts # API client with auth
|
||||
├── middleware/
|
||||
│ └── auth.ts # Elysia auth plugin
|
||||
├── modules/
|
||||
│ └── auth/
|
||||
│ └── service.ts # User sync logic
|
||||
├── providers/
|
||||
│ └── AuthProvider.tsx # React auth context
|
||||
└── app/
|
||||
└── layout.tsx # Root layout with AuthProvider
|
||||
```
|
||||
|
||||
## Next Steps (Phase 2 & 3)
|
||||
|
||||
### Phase 2: Role-Based Access Control (RBAC)
|
||||
|
||||
- Store user roles in database
|
||||
- Add role claims to token verification
|
||||
- Create role-based route protection
|
||||
- Add admin/role management UI
|
||||
|
||||
### Phase 3: Multi-Tenant Support
|
||||
|
||||
- Add tenant_id to user schema
|
||||
- Filter data by tenant
|
||||
- Add tenant context to requests
|
||||
- Implement tenant isolation
|
||||
|
||||
## References
|
||||
|
||||
- [Keycloak Documentation](https://www.keycloak.org/documentation)
|
||||
- [OpenID Connect Core](https://openid.net/connect/)
|
||||
- [ElysiaJS Documentation](https://elysiajs.com/)
|
||||
- [Drizzle ORM Documentation](https://orm.drizzle.team/)
|
||||
- [Next.js Documentation](https://nextjs.org/docs)
|
||||
13
drizzle.config.ts
Normal file
13
drizzle.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
import { config } from "dotenv";
|
||||
|
||||
config({ path: ".env" });
|
||||
|
||||
export default {
|
||||
schema: "./src/database/schema",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || "",
|
||||
},
|
||||
} satisfies Config;
|
||||
1907
package-lock.json
generated
1907
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@elysiajs/eden": "^1.4.9",
|
||||
"@hugeicons/core-free-icons": "^4.1.1",
|
||||
"@hugeicons/react": "^1.1.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
@@ -20,15 +21,22 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"elysia": "^1.4.28",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"jose": "^6.2.2",
|
||||
"kbar": "^0.1.0-beta.48",
|
||||
"keycloak": "^1.2.0",
|
||||
"keycloak-js": "^26.2.4",
|
||||
"lucide-react": "^1.8.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"nuqs": "^2.8.9",
|
||||
"pg": "^8.20.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
@@ -47,12 +55,15 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
28
src/app/[branch]/customers/page.tsx
Normal file
28
src/app/[branch]/customers/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import PageContainer from "@/components/layout/page-container";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Heading } from "@/components/ui/heading";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function Page({ params }) {
|
||||
const { branch } = await params;
|
||||
console.log("branch", branch);
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex flex-1 flex-col space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title="ลูกค้า" description="จัดการลูกค้า" />
|
||||
<Link
|
||||
href="/dashboard/product/new"
|
||||
className={cn(buttonVariants(), "text-xs md:text-sm")}
|
||||
>
|
||||
<IconPlus className="mr-2 h-4 w-4" /> เพิ่มลูกค้า
|
||||
</Link>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
4
src/app/[branch]/dashboard/page.tsx
Normal file
4
src/app/[branch]/dashboard/page.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export default async function Page({ params }) {
|
||||
const { branch } = await params;
|
||||
return <main>dashboard</main>;
|
||||
}
|
||||
34
src/app/[branch]/layout.tsx
Normal file
34
src/app/[branch]/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import KBar from "@/components/kbar";
|
||||
import AppSidebar from "@/components/layout/app-sidebar";
|
||||
import Header from "@/components/layout/header";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "ALLA-OS",
|
||||
description: "ALLA-OS",
|
||||
};
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Persisting the sidebar state in the cookie.
|
||||
const cookieStore = await cookies();
|
||||
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true";
|
||||
return (
|
||||
<KBar>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<Header />
|
||||
{/* page main content */}
|
||||
{children}
|
||||
{/* page main content ends */}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</KBar>
|
||||
);
|
||||
}
|
||||
4
src/app/[branch]/quotations/page.tsx
Normal file
4
src/app/[branch]/quotations/page.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export default async function Page({ params }) {
|
||||
const { branch } = await params;
|
||||
return <main>quotations</main>;
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
import KBar from '@/components/kbar';
|
||||
import AppSidebar from '@/components/layout/app-sidebar';
|
||||
import Header from '@/components/layout/header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import type { Metadata } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
import KBar from "@/components/kbar";
|
||||
import AppSidebar from "@/components/layout/app-sidebar";
|
||||
import Header from "@/components/layout/header";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Next Shadcn Dashboard Starter',
|
||||
description: 'Basic dashboard with Next.js and Shadcn'
|
||||
title: "Admin",
|
||||
description: "Admin",
|
||||
};
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Persisting the sidebar state in the cookie.
|
||||
const cookieStore = await cookies();
|
||||
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true"
|
||||
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true";
|
||||
return (
|
||||
<KBar>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
|
||||
16
src/app/api/[[...slugs]]/route.ts
Normal file
16
src/app/api/[[...slugs]]/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { customers } from "@/modules/customers/controller";
|
||||
import { quotations } from "@/modules/quotations/controller";
|
||||
import { auth } from "@/modules/auth/controller";
|
||||
|
||||
// Create main Elysia instance with all modules
|
||||
const app = new Elysia({ prefix: "/api" })
|
||||
.use(customers) // /api/customers/*
|
||||
.use(quotations) // /api/quotations/*
|
||||
.use(auth); // /api/auth/*
|
||||
|
||||
// Export handlers for Next.js
|
||||
export const GET = app.fetch;
|
||||
export const POST = app.fetch;
|
||||
export const PUT = app.fetch;
|
||||
export const DELETE = app.fetch;
|
||||
@@ -2,6 +2,7 @@ import Providers from "@/components/layout/providers";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { fontVariables } from "@/lib/font";
|
||||
import ThemeProvider from "@/components/layout/ThemeToggle/theme-provider";
|
||||
import { AuthProvider } from "@/providers/AuthProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
@@ -16,8 +17,8 @@ const META_THEME_COLORS = {
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Next Shadcn",
|
||||
description: "Basic dashboard with Next.js and Shadcn",
|
||||
title: "ALLA-OS",
|
||||
description: "ALLA-OS [order-system]",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
@@ -65,10 +66,12 @@ export default async function RootLayout({
|
||||
disableTransitionOnChange
|
||||
enableColorScheme
|
||||
>
|
||||
<AuthProvider>
|
||||
<Providers activeThemeValue={activeThemeValue as string}>
|
||||
<Toaster />
|
||||
{children}
|
||||
</Providers>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Page() {
|
||||
redirect("/admin/overview");
|
||||
redirect("/alla/customers");
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar";
|
||||
//import { UserAvatarProfile } from "@/components/user-avatar-profile";
|
||||
import { navItems } from "@/constants/data";
|
||||
import { navItems, tenantNavConfig } from "@/constants/data";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
|
||||
import {
|
||||
@@ -47,31 +47,36 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { Icons } from "../icons";
|
||||
import { OrgSwitcher } from "../org-switcher";
|
||||
import { useAuth } from "@/providers/AuthProvider";
|
||||
export const company = {
|
||||
name: "Acme Inc",
|
||||
name: "ALLA",
|
||||
logo: IconPhotoUp,
|
||||
plan: "Enterprise",
|
||||
};
|
||||
|
||||
const tenants = [
|
||||
{ id: "1", name: "Acme Inc" },
|
||||
{ id: "2", name: "Beta Corp" },
|
||||
{ id: "3", name: "Gamma Ltd" },
|
||||
{ id: "1", name: "ALLA" },
|
||||
{ id: "2", name: "ONVALLA" },
|
||||
];
|
||||
|
||||
export default function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { isOpen } = useMediaQuery();
|
||||
const router = useRouter();
|
||||
const handleSwitchTenant = (_tenantId: string) => {
|
||||
// Tenant switching functionality would be implemented here
|
||||
const [activeTenant, setActiveTenant] = React.useState(tenants[0]);
|
||||
const { isAuthenticated, userInfo, logout } = useAuth();
|
||||
|
||||
const handleSwitchTenant = (tenantId: string) => {
|
||||
const newTenant = tenants.find((t) => t.id === tenantId);
|
||||
if (newTenant) {
|
||||
setActiveTenant(newTenant);
|
||||
// Optional: Redirect to the tenant's dashboard after switching
|
||||
// router.push(tenantNavConfig[tenantId][0]?.url || "/");
|
||||
}
|
||||
};
|
||||
|
||||
const activeTenant = tenants[0];
|
||||
|
||||
React.useEffect(() => {
|
||||
// Side effects based on sidebar state changes
|
||||
}, [isOpen]);
|
||||
// Get navItems based on active tenant
|
||||
const currentNavItems = tenantNavConfig[activeTenant.id] || navItems;
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -86,7 +91,7 @@ export default function AppSidebar() {
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Overview</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => {
|
||||
{currentNavItems.map((item) => {
|
||||
const Icon = item.icon ? Icons[item.icon] : Icons.logo;
|
||||
return item?.items && item?.items?.length > 0 ? (
|
||||
<Collapsible
|
||||
@@ -151,13 +156,18 @@ export default function AppSidebar() {
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
{/* {user && (
|
||||
<UserAvatarProfile
|
||||
className='h-8 w-8 rounded-lg'
|
||||
showInfo
|
||||
user={user}
|
||||
/>
|
||||
)} */}
|
||||
{userInfo && (
|
||||
<div className="flex items-center gap-2">
|
||||
<IconUserCircle className="h-8 w-8" />
|
||||
<div className="flex flex-col text-center">
|
||||
<span className="text-sm ">
|
||||
{userInfo?.name ||
|
||||
userInfo?.preferred_username ||
|
||||
"User"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<IconChevronsDown className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -168,38 +178,33 @@ export default function AppSidebar() {
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="px-1 py-1.5">
|
||||
{/* {user && (
|
||||
<UserAvatarProfile
|
||||
className='h-8 w-8 rounded-lg'
|
||||
showInfo
|
||||
user={user}
|
||||
/>
|
||||
)} */}
|
||||
<div className="flex items-center gap-3 px-1 py-1.5 text-left text-sm">
|
||||
{userInfo && (
|
||||
<>
|
||||
<IconUserCircle className="h-8 w-8 rounded-lg bg-muted" />
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{userInfo?.name ||
|
||||
userInfo?.preferred_username ||
|
||||
"User"}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push("/dashboard/profile")}
|
||||
>
|
||||
<IconUserCircle className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconCreditCard className="mr-2 h-4 w-4" />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconBell className="mr-2 h-4 w-4" />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => logout()}>
|
||||
<IconLogout className="mr-2 h-4 w-4" />
|
||||
{/* <SignOutButton redirectUrl='/auth/sign-in' /> */}
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
import React from 'react';
|
||||
import { SidebarTrigger } from '../ui/sidebar';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { Breadcrumbs } from '../breadcrumbs';
|
||||
import SearchInput from '../search-input';
|
||||
import { UserNav } from './user-nav';
|
||||
import { ThemeSelector } from '../theme-selector';
|
||||
import { ModeToggle } from './ThemeToggle/theme-toggle';
|
||||
import CtaGithub from './cta-github';
|
||||
import React from "react";
|
||||
import { SidebarTrigger } from "../ui/sidebar";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { Breadcrumbs } from "../breadcrumbs";
|
||||
|
||||
import { UserNav } from "./user-nav";
|
||||
import { ThemeSelector } from "../theme-selector";
|
||||
import { ModeToggle } from "./ThemeToggle/theme-toggle";
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className='flex h-16 shrink-0 items-center justify-between gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12'>
|
||||
<div className='flex items-center gap-2 px-4'>
|
||||
<SidebarTrigger className='-ml-1' />
|
||||
<Separator orientation='vertical' className='mr-2 h-4' />
|
||||
<header className="flex h-16 shrink-0 items-center justify-between gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2 px-4'>
|
||||
<CtaGithub />
|
||||
<div className='hidden md:flex'>
|
||||
<SearchInput />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<UserNav />
|
||||
<ModeToggle />
|
||||
<ThemeSelector />
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronsUpDown, GalleryVerticalEnd } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronsUpDown, GalleryVerticalEnd } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar';
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
interface Tenant {
|
||||
id: string;
|
||||
@@ -23,7 +23,7 @@ interface Tenant {
|
||||
export function OrgSwitcher({
|
||||
tenants,
|
||||
defaultTenant,
|
||||
onTenantSwitch
|
||||
onTenantSwitch,
|
||||
}: {
|
||||
tenants: Tenant[];
|
||||
defaultTenant: Tenant;
|
||||
@@ -49,31 +49,31 @@ export function OrgSwitcher({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div className='bg-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'>
|
||||
<GalleryVerticalEnd className='size-4' />
|
||||
<div className="bg-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||
<GalleryVerticalEnd className="size-4" />
|
||||
</div>
|
||||
<div className='flex flex-col gap-0.5 leading-none'>
|
||||
<span className='font-semibold'>Next Starter</span>
|
||||
<span className=''>{selectedTenant.name}</span>
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span className="font-semibold">ALLA OS</span>
|
||||
<span className="">{selectedTenant.name}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className='ml-auto' />
|
||||
<ChevronsUpDown className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-[--radix-dropdown-menu-trigger-width]'
|
||||
align='start'
|
||||
className="w-[--radix-dropdown-menu-trigger-width]"
|
||||
align="start"
|
||||
>
|
||||
{tenants.map((tenant) => (
|
||||
<DropdownMenuItem
|
||||
key={tenant.id}
|
||||
onSelect={() => handleTenantSwitch(tenant)}
|
||||
>
|
||||
{tenant.name}{' '}
|
||||
{tenant.name}{" "}
|
||||
{tenant.id === selectedTenant.id && (
|
||||
<Check className='ml-auto' />
|
||||
<Check className="ml-auto" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NavGroup } from '@/types';
|
||||
import { NavGroup } from "@/types";
|
||||
|
||||
/**
|
||||
* Navigation configuration with RBAC support
|
||||
@@ -35,163 +35,163 @@ import { NavGroup } from '@/types';
|
||||
*/
|
||||
export const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: 'Overview',
|
||||
label: "Overview",
|
||||
items: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
url: '/dashboard/overview',
|
||||
icon: 'dashboard',
|
||||
title: "Dashboard",
|
||||
url: "/dashboard/overview",
|
||||
icon: "dashboard",
|
||||
isActive: false,
|
||||
shortcut: ['d', 'd'],
|
||||
items: []
|
||||
shortcut: ["d", "d"],
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: 'Workspaces',
|
||||
url: '/dashboard/workspaces',
|
||||
icon: 'workspace',
|
||||
isActive: false,
|
||||
items: []
|
||||
},
|
||||
{
|
||||
title: 'Teams',
|
||||
url: '/dashboard/workspaces/team',
|
||||
icon: 'teams',
|
||||
title: "Workspaces",
|
||||
url: "/dashboard/workspaces",
|
||||
icon: "workspace",
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { requireOrg: true }
|
||||
},
|
||||
{
|
||||
title: 'Product',
|
||||
url: '/dashboard/product',
|
||||
icon: 'product',
|
||||
shortcut: ['p', 'p'],
|
||||
title: "Teams",
|
||||
url: "/dashboard/workspaces/team",
|
||||
icon: "teams",
|
||||
isActive: false,
|
||||
items: []
|
||||
items: [],
|
||||
access: { requireOrg: true },
|
||||
},
|
||||
{
|
||||
title: 'Users',
|
||||
url: '/dashboard/users',
|
||||
icon: 'teams',
|
||||
shortcut: ['u', 'u'],
|
||||
title: "Product",
|
||||
url: "/dashboard/product",
|
||||
icon: "product",
|
||||
shortcut: ["p", "p"],
|
||||
isActive: false,
|
||||
items: []
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: 'Kanban',
|
||||
url: '/dashboard/kanban',
|
||||
icon: 'kanban',
|
||||
shortcut: ['k', 'k'],
|
||||
title: "Users",
|
||||
url: "/dashboard/users",
|
||||
icon: "teams",
|
||||
shortcut: ["u", "u"],
|
||||
isActive: false,
|
||||
items: []
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: 'Chat',
|
||||
url: '/dashboard/chat',
|
||||
icon: 'chat',
|
||||
shortcut: ['c', 'c'],
|
||||
title: "Kanban",
|
||||
url: "/dashboard/kanban",
|
||||
icon: "kanban",
|
||||
shortcut: ["k", "k"],
|
||||
isActive: false,
|
||||
items: []
|
||||
}
|
||||
]
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
label: 'Elements',
|
||||
title: "Chat",
|
||||
url: "/dashboard/chat",
|
||||
icon: "chat",
|
||||
shortcut: ["c", "c"],
|
||||
isActive: false,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Elements",
|
||||
items: [
|
||||
{
|
||||
title: 'Forms',
|
||||
url: '#',
|
||||
icon: 'forms',
|
||||
title: "Forms",
|
||||
url: "#",
|
||||
icon: "forms",
|
||||
isActive: true,
|
||||
items: [
|
||||
{
|
||||
title: 'Basic Form',
|
||||
url: '/dashboard/forms/basic',
|
||||
icon: 'forms',
|
||||
shortcut: ['f', 'f']
|
||||
title: "Basic Form",
|
||||
url: "/dashboard/forms/basic",
|
||||
icon: "forms",
|
||||
shortcut: ["f", "f"],
|
||||
},
|
||||
{
|
||||
title: 'Multi-Step Form',
|
||||
url: '/dashboard/forms/multi-step',
|
||||
icon: 'forms'
|
||||
title: "Multi-Step Form",
|
||||
url: "/dashboard/forms/multi-step",
|
||||
icon: "forms",
|
||||
},
|
||||
{
|
||||
title: 'Sheet & Dialog',
|
||||
url: '/dashboard/forms/sheet-form',
|
||||
icon: 'forms'
|
||||
title: "Sheet & Dialog",
|
||||
url: "/dashboard/forms/sheet-form",
|
||||
icon: "forms",
|
||||
},
|
||||
{
|
||||
title: 'Advanced Patterns',
|
||||
url: '/dashboard/forms/advanced',
|
||||
icon: 'forms'
|
||||
}
|
||||
]
|
||||
title: "Advanced Patterns",
|
||||
url: "/dashboard/forms/advanced",
|
||||
icon: "forms",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'React Query',
|
||||
url: '/dashboard/react-query',
|
||||
icon: 'code',
|
||||
title: "React Query",
|
||||
url: "/dashboard/react-query",
|
||||
icon: "code",
|
||||
isActive: false,
|
||||
items: []
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: 'Icons',
|
||||
url: '/dashboard/elements/icons',
|
||||
icon: 'palette',
|
||||
title: "Icons",
|
||||
url: "/dashboard/elements/icons",
|
||||
icon: "palette",
|
||||
isActive: false,
|
||||
items: []
|
||||
}
|
||||
]
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
label: "",
|
||||
items: [
|
||||
{
|
||||
title: 'Pro',
|
||||
url: '#',
|
||||
icon: 'pro',
|
||||
title: "Pro",
|
||||
url: "#",
|
||||
icon: "pro",
|
||||
isActive: true,
|
||||
items: [
|
||||
{
|
||||
title: 'Exclusive',
|
||||
url: '/dashboard/exclusive',
|
||||
icon: 'exclusive',
|
||||
shortcut: ['e', 'e']
|
||||
}
|
||||
]
|
||||
title: "Exclusive",
|
||||
url: "/dashboard/exclusive",
|
||||
icon: "exclusive",
|
||||
shortcut: ["e", "e"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Account',
|
||||
url: '#',
|
||||
icon: 'account',
|
||||
title: "Account",
|
||||
url: "#",
|
||||
icon: "account",
|
||||
isActive: true,
|
||||
items: [
|
||||
{
|
||||
title: 'Profile',
|
||||
url: '/dashboard/profile',
|
||||
icon: 'profile',
|
||||
shortcut: ['m', 'm']
|
||||
title: "Profile",
|
||||
url: "/dashboard/profile",
|
||||
icon: "profile",
|
||||
shortcut: ["m", "m"],
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
url: '/dashboard/notifications',
|
||||
icon: 'notification',
|
||||
shortcut: ['n', 'n']
|
||||
title: "Notifications",
|
||||
url: "/dashboard/notifications",
|
||||
icon: "notification",
|
||||
shortcut: ["n", "n"],
|
||||
},
|
||||
{
|
||||
title: 'Billing',
|
||||
url: '/dashboard/billing',
|
||||
icon: 'billing',
|
||||
shortcut: ['b', 'b'],
|
||||
access: { requireOrg: true }
|
||||
title: "Billing",
|
||||
url: "/dashboard/billing",
|
||||
icon: "billing",
|
||||
shortcut: ["b", "b"],
|
||||
access: { requireOrg: true },
|
||||
},
|
||||
{
|
||||
title: 'Login',
|
||||
shortcut: ['l', 'l'],
|
||||
url: '/',
|
||||
icon: 'login'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
title: "Login",
|
||||
shortcut: ["l", "l"],
|
||||
url: "/",
|
||||
icon: "login",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,4 +1,42 @@
|
||||
import { NavItem } from '@/types';
|
||||
import { NavItem } from "@/types";
|
||||
|
||||
// Tenant-specific navigation configurations
|
||||
export const tenantNavConfig: Record<string, NavItem[]> = {
|
||||
"1": [
|
||||
// ALLA tenant
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/alla/dashboard/overview",
|
||||
icon: "dashboard",
|
||||
isActive: false,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: "Customers",
|
||||
url: "/alla/customers",
|
||||
icon: "product",
|
||||
isActive: false,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
"2": [
|
||||
// ONVALLA tenant
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/onvilla/dashboard/overview",
|
||||
icon: "dashboard",
|
||||
isActive: false,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: "Customers",
|
||||
url: "/onvilla/customers",
|
||||
icon: "product",
|
||||
isActive: false,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
photo_url: string;
|
||||
@@ -14,50 +52,19 @@ export type Product = {
|
||||
//Info: The following data is used for the sidebar navigation and Cmd K bar.
|
||||
export const navItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
url: '/dashboard/overview',
|
||||
icon: 'dashboard',
|
||||
title: "Dashboard",
|
||||
url: "/alla/dashboard/overview",
|
||||
icon: "dashboard",
|
||||
isActive: false,
|
||||
shortcut: ['d', 'd'],
|
||||
items: [] // Empty array as there are no child items for Dashboard
|
||||
items: [], // Empty array as there are no child items for Dashboard
|
||||
},
|
||||
{
|
||||
title: 'Product',
|
||||
url: '/dashboard/product',
|
||||
icon: 'product',
|
||||
shortcut: ['p', 'p'],
|
||||
title: "Customers",
|
||||
url: "/alla/customers",
|
||||
icon: "product",
|
||||
isActive: false,
|
||||
items: [] // No child items
|
||||
items: [], // No child items
|
||||
},
|
||||
{
|
||||
title: 'Account',
|
||||
url: '#', // Placeholder as there is no direct link for the parent
|
||||
icon: 'billing',
|
||||
isActive: true,
|
||||
|
||||
items: [
|
||||
{
|
||||
title: 'Profile',
|
||||
url: '/dashboard/profile',
|
||||
icon: 'userPen',
|
||||
shortcut: ['m', 'm']
|
||||
},
|
||||
{
|
||||
title: 'Login',
|
||||
shortcut: ['l', 'l'],
|
||||
url: '/',
|
||||
icon: 'login'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Kanban',
|
||||
url: '/dashboard/kanban',
|
||||
icon: 'kanban',
|
||||
shortcut: ['k', 'k'],
|
||||
isActive: false,
|
||||
items: [] // No child items
|
||||
}
|
||||
];
|
||||
|
||||
export interface SaleUser {
|
||||
@@ -72,42 +79,42 @@ export interface SaleUser {
|
||||
export const recentSalesData: SaleUser[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Olivia Martin',
|
||||
email: 'olivia.martin@email.com',
|
||||
amount: '+$1,999.00',
|
||||
image: 'https://api.slingacademy.com/public/sample-users/1.png',
|
||||
initials: 'OM'
|
||||
name: "Olivia Martin",
|
||||
email: "olivia.martin@email.com",
|
||||
amount: "+$1,999.00",
|
||||
image: "https://api.slingacademy.com/public/sample-users/1.png",
|
||||
initials: "OM",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Jackson Lee',
|
||||
email: 'jackson.lee@email.com',
|
||||
amount: '+$39.00',
|
||||
image: 'https://api.slingacademy.com/public/sample-users/2.png',
|
||||
initials: 'JL'
|
||||
name: "Jackson Lee",
|
||||
email: "jackson.lee@email.com",
|
||||
amount: "+$39.00",
|
||||
image: "https://api.slingacademy.com/public/sample-users/2.png",
|
||||
initials: "JL",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Isabella Nguyen',
|
||||
email: 'isabella.nguyen@email.com',
|
||||
amount: '+$299.00',
|
||||
image: 'https://api.slingacademy.com/public/sample-users/3.png',
|
||||
initials: 'IN'
|
||||
name: "Isabella Nguyen",
|
||||
email: "isabella.nguyen@email.com",
|
||||
amount: "+$299.00",
|
||||
image: "https://api.slingacademy.com/public/sample-users/3.png",
|
||||
initials: "IN",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'William Kim',
|
||||
email: 'will@email.com',
|
||||
amount: '+$99.00',
|
||||
image: 'https://api.slingacademy.com/public/sample-users/4.png',
|
||||
initials: 'WK'
|
||||
name: "William Kim",
|
||||
email: "will@email.com",
|
||||
amount: "+$99.00",
|
||||
image: "https://api.slingacademy.com/public/sample-users/4.png",
|
||||
initials: "WK",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Sofia Davis',
|
||||
email: 'sofia.davis@email.com',
|
||||
amount: '+$39.00',
|
||||
image: 'https://api.slingacademy.com/public/sample-users/5.png',
|
||||
initials: 'SD'
|
||||
}
|
||||
name: "Sofia Davis",
|
||||
email: "sofia.davis@email.com",
|
||||
amount: "+$39.00",
|
||||
image: "https://api.slingacademy.com/public/sample-users/5.png",
|
||||
initials: "SD",
|
||||
},
|
||||
];
|
||||
|
||||
9
src/database/db.ts
Normal file
9
src/database/db.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
export const db = drizzle(pool, { schema });
|
||||
1
src/database/schema/index.ts
Normal file
1
src/database/schema/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./users";
|
||||
13
src/database/schema/users.ts
Normal file
13
src/database/schema/users.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
keycloakId: text("keycloak_id").notNull().unique(),
|
||||
email: text("email").notNull(),
|
||||
name: text("name").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
@@ -1,12 +1,36 @@
|
||||
const BASE_URL = '/api';
|
||||
const BASE_URL = "/api";
|
||||
|
||||
export async function apiClient<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> {
|
||||
// Get token from global window object (set by Keycloak client)
|
||||
const token =
|
||||
typeof window !== "undefined" ? (window as any).__KEYCLOAK_TOKEN__ : null;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...((options?.headers as Record<string, string>) || {}),
|
||||
};
|
||||
|
||||
// Add Authorization header if token exists
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
export async function apiClient<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${endpoint}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Handle 401 - token expired
|
||||
if (res.status === 401) {
|
||||
// Trigger token refresh by dispatching event
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("token-expired"));
|
||||
}
|
||||
}
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
143
src/lib/keycloak-client.ts
Normal file
143
src/lib/keycloak-client.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import Keycloak from "keycloak-js";
|
||||
|
||||
const KEYCLOAK_URL =
|
||||
process.env.NEXT_PUBLIC_KEYCLOAK_URL || "http://localhost:8080";
|
||||
const KEYCLOAK_REALM = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || "allaos";
|
||||
const KEYCLOAK_CLIENT_ID =
|
||||
process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || "allaos-frontend";
|
||||
|
||||
// Initialize Keycloak instance
|
||||
const keycloak = new Keycloak({
|
||||
url: KEYCLOAK_URL,
|
||||
realm: KEYCLOAK_REALM,
|
||||
clientId: KEYCLOAK_CLIENT_ID,
|
||||
});
|
||||
|
||||
// Token refresh interval (in seconds)
|
||||
const MIN_TOKEN_VALIDITY = 30; // Refresh 30 seconds before expiry
|
||||
|
||||
/**
|
||||
* Initialize Keycloak and authenticate user
|
||||
*/
|
||||
export async function initKeycloak(): Promise<boolean> {
|
||||
try {
|
||||
const authenticated = await keycloak.init({
|
||||
onLoad: "login-required",
|
||||
checkLoginIframe: false,
|
||||
pkceMethod: "S256",
|
||||
});
|
||||
|
||||
if (authenticated) {
|
||||
// Store token in window object for API client
|
||||
updateGlobalToken();
|
||||
|
||||
// Start token refresh timer
|
||||
startTokenRefresh();
|
||||
|
||||
console.log("User authenticated:", keycloak.tokenParsed);
|
||||
}
|
||||
|
||||
return authenticated;
|
||||
} catch (error) {
|
||||
console.error("Keycloak initialization failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update global window object with current token
|
||||
*/
|
||||
function updateGlobalToken() {
|
||||
if (typeof window !== "undefined" && keycloak.token) {
|
||||
(window as any).__KEYCLOAK_TOKEN__ = keycloak.token;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start automatic token refresh
|
||||
*/
|
||||
function startTokenRefresh() {
|
||||
// Clear existing interval if any
|
||||
if ((window as any).__TOKEN_REFRESH_INTERVAL__) {
|
||||
clearInterval((window as any).__TOKEN_REFRESH_INTERVAL__);
|
||||
}
|
||||
|
||||
// Set up refresh interval
|
||||
(window as any).__TOKEN_REFRESH_INTERVAL__ = setInterval(async () => {
|
||||
try {
|
||||
const refreshed = await keycloak.updateToken(MIN_TOKEN_VALIDITY);
|
||||
if (refreshed) {
|
||||
console.log("Token refreshed");
|
||||
updateGlobalToken();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh token:", error);
|
||||
// Redirect to login on refresh failure
|
||||
await keycloak.login();
|
||||
}
|
||||
}, 1000); // Check every second
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
export async function logout() {
|
||||
try {
|
||||
// Clear refresh interval
|
||||
if ((window as any).__TOKEN_REFRESH_INTERVAL__) {
|
||||
clearInterval((window as any).__TOKEN_REFRESH_INTERVAL__);
|
||||
}
|
||||
|
||||
// Clear global token
|
||||
if (typeof window !== "undefined") {
|
||||
delete (window as any).__KEYCLOAK_TOKEN__;
|
||||
}
|
||||
|
||||
await keycloak.logout({ redirectUri: window.location.origin });
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user info
|
||||
*/
|
||||
export function getUserInfo() {
|
||||
return keycloak.tokenParsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current token
|
||||
*/
|
||||
export function getToken() {
|
||||
return keycloak.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!keycloak.authenticated;
|
||||
}
|
||||
|
||||
// Listen for token expired events from API client
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("token-expired", async () => {
|
||||
console.log("Token expired, attempting refresh...");
|
||||
try {
|
||||
const refreshed = await keycloak.updateToken(MIN_TOKEN_VALIDITY);
|
||||
if (refreshed) {
|
||||
updateGlobalToken();
|
||||
console.log("Token refreshed after expiry");
|
||||
} else {
|
||||
console.log("Could not refresh token, redirecting to login");
|
||||
await keycloak.login();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh expired token:", error);
|
||||
await keycloak.login();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default keycloak;
|
||||
59
src/lib/keycloak.ts
Normal file
59
src/lib/keycloak.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { jwtVerify, createRemoteJWKSet } from "jose";
|
||||
|
||||
const KEYCLOAK_URL = process.env.KEYCLOAK_URL || "http://localhost:8080";
|
||||
const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || "allaos";
|
||||
|
||||
// JWKS endpoint for verifying tokens
|
||||
const JWKS_URL = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs`;
|
||||
|
||||
// Create JWKS cache
|
||||
const JWKS = createRemoteJWKSet(new URL(JWKS_URL));
|
||||
|
||||
export interface KeycloakTokenPayload {
|
||||
sub: string; // User ID
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
iss: string;
|
||||
aud: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Keycloak JWT access token
|
||||
* @param token The JWT token string
|
||||
* @returns Decoded token payload
|
||||
* @throws Error if token is invalid
|
||||
*/
|
||||
export async function verifyToken(
|
||||
token: string,
|
||||
): Promise<KeycloakTokenPayload> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWKS, {
|
||||
issuer: `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}`,
|
||||
audience: process.env.KEYCLOAK_CLIENT_ID,
|
||||
});
|
||||
|
||||
return payload as KeycloakTokenPayload;
|
||||
} catch (error) {
|
||||
console.error("Token verification failed:", error);
|
||||
throw new Error("Invalid or expired token");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Bearer token from Authorization header
|
||||
* @param authHeader The Authorization header value
|
||||
* @returns The token string or null
|
||||
*/
|
||||
export function extractToken(authHeader: string | null): string | null {
|
||||
if (!authHeader) return null;
|
||||
|
||||
const parts = authHeader.split(" ");
|
||||
if (parts.length !== 2 || parts[0] !== "Bearer") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts[1];
|
||||
}
|
||||
132
src/lib/mock-data.ts
Normal file
132
src/lib/mock-data.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Customer } from "@/types/customer";
|
||||
|
||||
export const mockCustomers: Customer[] = [
|
||||
{
|
||||
id: "cust-001",
|
||||
branch: "branch-01",
|
||||
name: "สมชาย ใจดี",
|
||||
email: "somchai@example.com",
|
||||
phone: "081-234-5678",
|
||||
company: "บริษัท ไทยธุรกิจ จำกัด",
|
||||
address: "123 ถนนสุขุมวิท แขวงคลองตัน เขตคลองเตย กรุงเทพฯ 10110",
|
||||
status: "active",
|
||||
createdAt: "2024-01-15T09:00:00Z",
|
||||
updatedAt: "2024-01-15T09:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "cust-002",
|
||||
branch: "branch-01",
|
||||
name: "วิภา สุขสันต์",
|
||||
email: "wipa@example.com",
|
||||
phone: "082-345-6789",
|
||||
company: "บริษัท นวัตกรรมไทย จำกัด",
|
||||
address: "456 ถนนพระราม 4 แขวงคลองเตย เขตคลองเตย กรุงเทพฯ 10110",
|
||||
status: "active",
|
||||
createdAt: "2024-02-20T10:30:00Z",
|
||||
updatedAt: "2024-02-20T10:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "cust-003",
|
||||
branch: "branch-01",
|
||||
name: "อนุชิต กล้าหาญ",
|
||||
email: "anuchit@example.com",
|
||||
phone: "083-456-7890",
|
||||
company: "บริษัท พัฒนาธุรกิจ จำกัด",
|
||||
address: "789 ถนนสีลม แขวงสีลม เขตบางรัก กรุงเทพฯ 10500",
|
||||
status: "active",
|
||||
createdAt: "2024-03-10T14:15:00Z",
|
||||
updatedAt: "2024-03-10T14:15:00Z",
|
||||
},
|
||||
{
|
||||
id: "cust-004",
|
||||
branch: "branch-02",
|
||||
name: "มานี มีสุข",
|
||||
email: "manee@example.com",
|
||||
phone: "084-567-8901",
|
||||
company: "บริษัท ค้าส่งสินค้า จำกัด",
|
||||
address: "321 ถนนจรัญสนิทวงศ์ แขวงบางพลัด เขตบางพลัด กรุงเทพฯ 10700",
|
||||
status: "active",
|
||||
createdAt: "2024-01-25T11:00:00Z",
|
||||
updatedAt: "2024-01-25T11:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "cust-005",
|
||||
branch: "branch-02",
|
||||
name: "ประยุทธ์ จริงใจ",
|
||||
email: "prayut@example.com",
|
||||
phone: "085-678-9012",
|
||||
company: "บริษัท อิเล็กทรอนิกส์ ไทย จำกัด",
|
||||
address: "654 ถนนเพชรบุรี แขวงทุ่งพญาไท เขตราชเทวี กรุงเทพฯ 10400",
|
||||
status: "inactive",
|
||||
createdAt: "2024-02-05T08:45:00Z",
|
||||
updatedAt: "2024-04-01T10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "cust-006",
|
||||
branch: "branch-02",
|
||||
name: "สมหญิง แก้วสะอาด",
|
||||
email: "somying@example.com",
|
||||
phone: "086-789-0123",
|
||||
company: "บริษัท อาหารแห้ง จำกัด",
|
||||
address: "987 ถนนลาดพร้าว แขวงลาดพร้าว เขตลาดพร้าว กรุงเทพฯ 10230",
|
||||
status: "pending",
|
||||
createdAt: "2024-04-15T16:20:00Z",
|
||||
updatedAt: "2024-04-15T16:20:00Z",
|
||||
},
|
||||
{
|
||||
id: "cust-007",
|
||||
branch: "head-office",
|
||||
name: "ภูมิ รักษ์โลก",
|
||||
email: "phumi@example.com",
|
||||
phone: "087-890-1234",
|
||||
company: "บริษัท เคมีภัณฑ์ ไทย จำกัด",
|
||||
address: "147 ถนนวิภาวดีรังสิต แขวงดอนเมือง เขตดอนเมือง กรุงเทพฯ 10210",
|
||||
status: "active",
|
||||
createdAt: "2024-01-10T09:30:00Z",
|
||||
updatedAt: "2024-01-10T09:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "cust-008",
|
||||
branch: "head-office",
|
||||
name: "กัญญา มีเมตตา",
|
||||
email: "kanya@example.com",
|
||||
phone: "088-901-2345",
|
||||
company: "บริษัท สิ่งทอ ไทย จำกัด",
|
||||
address: "258 ถนนพหลโยธิน แขวงสามเสนใน เขตพญาไท กรุงเทพฯ 10400",
|
||||
status: "active",
|
||||
createdAt: "2024-03-20T13:00:00Z",
|
||||
updatedAt: "2024-03-20T13:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "cust-009",
|
||||
branch: "head-office",
|
||||
name: "สุเมธ รัตนา",
|
||||
email: "sumet@example.com",
|
||||
phone: "089-012-3456",
|
||||
company: "บริษัท ก่อสร้าง รุ่งเรือง จำกัด",
|
||||
address: "369 ถนนนิมิตรใหม่ แขวงบางบอน เขตบางบอน กรุงเทพฯ 10150",
|
||||
status: "active",
|
||||
createdAt: "2024-02-28T15:45:00Z",
|
||||
updatedAt: "2024-02-28T15:45:00Z",
|
||||
},
|
||||
{
|
||||
id: "cust-010",
|
||||
branch: "branch-01",
|
||||
name: "นภา รัตนา",
|
||||
email: "napha@example.com",
|
||||
phone: "090-123-4567",
|
||||
company: "บริษัท ซอฟต์แวร์ ไทย จำกัด",
|
||||
address: "741 ถนนเทพรัตน แขวงบางนา เขตบางนา กรุงเทพฯ 10260",
|
||||
status: "pending",
|
||||
createdAt: "2024-04-10T11:30:00Z",
|
||||
updatedAt: "2024-04-10T11:30:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
export const getCustomersByBranch = (branch: string): Customer[] => {
|
||||
return mockCustomers.filter((customer) => customer.branch === branch);
|
||||
};
|
||||
|
||||
export const getCustomerById = (id: string): Customer | undefined => {
|
||||
return mockCustomers.find((customer) => customer.id === id);
|
||||
};
|
||||
65
src/middleware/auth.ts
Normal file
65
src/middleware/auth.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Elysia, type Context } from "elysia";
|
||||
import { verifyToken, extractToken } from "@/lib/keycloak";
|
||||
import { findOrCreateUser } from "@/modules/auth/service";
|
||||
import type { KeycloakTokenPayload } from "@/lib/keycloak";
|
||||
import type { User } from "@/database/schema";
|
||||
|
||||
// Extend Elysia context to include user
|
||||
declare module "elysia" {
|
||||
interface Context {
|
||||
user?: User;
|
||||
tokenPayload?: KeycloakTokenPayload;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elysia plugin for Keycloak authentication
|
||||
* Validates Bearer token and attaches user to context
|
||||
*/
|
||||
export const authPlugin = new Elysia({ name: "auth" }).derive(
|
||||
async ({ request, set }) => {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const token = extractToken(authHeader);
|
||||
|
||||
if (!token) {
|
||||
set.status = 401;
|
||||
return {
|
||||
error: "Unauthorized: No token provided",
|
||||
user: undefined,
|
||||
tokenPayload: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify token
|
||||
const payload = await verifyToken(token);
|
||||
|
||||
// Find or create user
|
||||
const user = await findOrCreateUser(payload);
|
||||
|
||||
return {
|
||||
user,
|
||||
tokenPayload: payload,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Authentication failed:", error);
|
||||
set.status = 401;
|
||||
return {
|
||||
error: "Unauthorized: Invalid or expired token",
|
||||
user: undefined,
|
||||
tokenPayload: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Helper to require authentication on a route
|
||||
* Returns 401 if no valid user
|
||||
*/
|
||||
export const requireAuth = (context: Context) => {
|
||||
if (!context.user) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
64
src/modules/auth/controller.ts
Normal file
64
src/modules/auth/controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { authPlugin } from "@/middleware/auth";
|
||||
import type { User } from "@/database/schema";
|
||||
import type { KeycloakTokenPayload } from "@/lib/keycloak";
|
||||
|
||||
export const auth = new Elysia({ prefix: "/auth", tags: ["auth"] })
|
||||
.use(authPlugin)
|
||||
// GET /api/auth/me - Get current user info
|
||||
.get(
|
||||
"/me",
|
||||
(context: any) => {
|
||||
const user = context.user as User;
|
||||
const tokenPayload = context.tokenPayload as KeycloakTokenPayload;
|
||||
|
||||
if (!user || !tokenPayload) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
keycloakId: user.keycloakId,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
tokenInfo: {
|
||||
sub: tokenPayload.sub,
|
||||
email: tokenPayload.email,
|
||||
name: tokenPayload.name,
|
||||
exp: tokenPayload.exp,
|
||||
iat: tokenPayload.iat,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
response: t.Object({
|
||||
success: t.Literal(true),
|
||||
data: t.Object({
|
||||
user: t.Object({
|
||||
id: t.String(),
|
||||
keycloakId: t.String(),
|
||||
email: t.String(),
|
||||
name: t.String(),
|
||||
createdAt: t.String(),
|
||||
}),
|
||||
tokenInfo: t.Object({
|
||||
sub: t.String(),
|
||||
email: t.Optional(t.String()),
|
||||
name: t.Optional(t.String()),
|
||||
exp: t.Number(),
|
||||
iat: t.Number(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
detail: {
|
||||
description: "Get current authenticated user information",
|
||||
security: [{ Bearer: [] }],
|
||||
},
|
||||
},
|
||||
);
|
||||
47
src/modules/auth/service.ts
Normal file
47
src/modules/auth/service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { db } from "@/database/db";
|
||||
import { users } from "@/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { KeycloakTokenPayload } from "@/lib/keycloak";
|
||||
|
||||
/**
|
||||
* Find or create user based on Keycloak token payload
|
||||
* @param payload Keycloak token payload
|
||||
* @returns User record from database
|
||||
*/
|
||||
export async function findOrCreateUser(payload: KeycloakTokenPayload) {
|
||||
// Try to find existing user by keycloakId
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.keycloakId, payload.sub))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
return existingUser[0];
|
||||
}
|
||||
|
||||
// Create new user if not found
|
||||
const newUser = {
|
||||
keycloakId: payload.sub,
|
||||
email: payload.email || "",
|
||||
name: payload.name || payload.preferred_username || "Unknown User",
|
||||
};
|
||||
|
||||
const [createdUser] = await db.insert(users).values(newUser).returning();
|
||||
return createdUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by Keycloak ID
|
||||
* @param keycloakId The Keycloak user ID
|
||||
* @returns User record or null
|
||||
*/
|
||||
export async function getUserByKeycloakId(keycloakId: string) {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.keycloakId, keycloakId))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
216
src/modules/customers/controller.ts
Normal file
216
src/modules/customers/controller.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import * as service from "./service";
|
||||
import { CustomerModel } from "./model";
|
||||
|
||||
// Create Elysia instance for customers module
|
||||
export const customers = new Elysia({
|
||||
prefix: "/customers",
|
||||
tags: ["customers"],
|
||||
})
|
||||
.model(CustomerModel)
|
||||
// GET /api/customers/:branch - Get all customers by branch
|
||||
.get(
|
||||
"/:branch",
|
||||
({ params, query }) => {
|
||||
const { branch } = params;
|
||||
const { status } = query as { status?: string };
|
||||
|
||||
const customers = service.getAllCustomers(
|
||||
branch,
|
||||
status as "active" | "inactive" | "pending" | undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: customers,
|
||||
count: customers.length,
|
||||
message: `Found ${customers.length} customer(s) for branch: ${branch}`,
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
}),
|
||||
query: t.Optional(
|
||||
t.Object({
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("active"),
|
||||
t.Literal("inactive"),
|
||||
t.Literal("pending"),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
),
|
||||
response: CustomerModel.CustomerList,
|
||||
detail: {
|
||||
description: "Get all customers for a specific branch",
|
||||
parameters: [
|
||||
{
|
||||
name: "branch",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Branch identifier (e.g., branch-01, branch-02, head-office)",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: {
|
||||
type: "string",
|
||||
enum: ["active", "inactive", "pending"],
|
||||
},
|
||||
description: "Filter customers by status",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
// GET /api/customers/:branch/:id - Get single customer by ID
|
||||
.get(
|
||||
"/:branch/:id",
|
||||
({ params }) => {
|
||||
const { branch, id } = params;
|
||||
const customer = service.getCustomerByIdAndBranch(branch, id);
|
||||
|
||||
if (!customer) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Customer not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: customer,
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
id: t.String(),
|
||||
}),
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
success: t.Literal(true),
|
||||
data: CustomerModel.Customer,
|
||||
}),
|
||||
t.Object({
|
||||
success: t.Literal(false),
|
||||
error: t.String(),
|
||||
}),
|
||||
]),
|
||||
detail: {
|
||||
description: "Get a single customer by ID and branch",
|
||||
},
|
||||
},
|
||||
)
|
||||
// POST /api/customers - Create new customer
|
||||
.post(
|
||||
"/",
|
||||
({ body }) => {
|
||||
const customer = service.createCustomer(body);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: customer,
|
||||
message: "Customer created successfully",
|
||||
};
|
||||
},
|
||||
{
|
||||
body: CustomerModel.CreateCustomer,
|
||||
response: t.Object({
|
||||
success: t.Boolean(),
|
||||
data: CustomerModel.Customer,
|
||||
message: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
description: "Create a new customer",
|
||||
},
|
||||
},
|
||||
)
|
||||
// PUT /api/customers/:branch/:id - Update customer
|
||||
.put(
|
||||
"/:branch/:id",
|
||||
({ params, body }) => {
|
||||
const { branch, id } = params;
|
||||
const customer = service.updateCustomer(branch, id, body);
|
||||
|
||||
if (!customer) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Customer not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: customer,
|
||||
message: "Customer updated successfully",
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
id: t.String(),
|
||||
}),
|
||||
body: CustomerModel.UpdateCustomer,
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
success: t.Literal(true),
|
||||
data: CustomerModel.Customer,
|
||||
message: t.String(),
|
||||
}),
|
||||
t.Object({
|
||||
success: t.Literal(false),
|
||||
error: t.String(),
|
||||
}),
|
||||
]),
|
||||
detail: {
|
||||
description: "Update an existing customer",
|
||||
},
|
||||
},
|
||||
)
|
||||
// DELETE /api/customers/:branch/:id - Delete customer
|
||||
.delete(
|
||||
"/:branch/:id",
|
||||
({ params }) => {
|
||||
const { branch, id } = params;
|
||||
const customer = service.deleteCustomer(branch, id);
|
||||
|
||||
if (!customer) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Customer not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: customer,
|
||||
message: "Customer deleted successfully",
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
id: t.String(),
|
||||
}),
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
success: t.Literal(true),
|
||||
data: CustomerModel.Customer,
|
||||
message: t.String(),
|
||||
}),
|
||||
t.Object({
|
||||
success: t.Literal(false),
|
||||
error: t.String(),
|
||||
}),
|
||||
]),
|
||||
detail: {
|
||||
description: "Delete a customer",
|
||||
},
|
||||
},
|
||||
);
|
||||
82
src/modules/customers/model.ts
Normal file
82
src/modules/customers/model.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { t } from "elysia";
|
||||
|
||||
// Schemas for validation
|
||||
export const CustomerModel = {
|
||||
Customer: t.Object({
|
||||
id: t.String(),
|
||||
branch: t.String(),
|
||||
name: t.String(),
|
||||
email: t.String({ format: "email" }),
|
||||
phone: t.String(),
|
||||
company: t.String(),
|
||||
address: t.String(),
|
||||
status: t.Union([
|
||||
t.Literal("active"),
|
||||
t.Literal("inactive"),
|
||||
t.Literal("pending"),
|
||||
]),
|
||||
createdAt: t.String({ format: "date-time" }),
|
||||
updatedAt: t.String({ format: "date-time" }),
|
||||
}),
|
||||
|
||||
CreateCustomer: t.Object({
|
||||
branch: t.String(),
|
||||
name: t.String(),
|
||||
email: t.String({ format: "email" }),
|
||||
phone: t.String(),
|
||||
company: t.String(),
|
||||
address: t.String(),
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("active"),
|
||||
t.Literal("inactive"),
|
||||
t.Literal("pending"),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
|
||||
UpdateCustomer: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
email: t.Optional(t.String({ format: "email" })),
|
||||
phone: t.Optional(t.String()),
|
||||
company: t.Optional(t.String()),
|
||||
address: t.Optional(t.String()),
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("active"),
|
||||
t.Literal("inactive"),
|
||||
t.Literal("pending"),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
|
||||
CustomerList: t.Object({
|
||||
success: t.Boolean(),
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
id: t.String(),
|
||||
branch: t.String(),
|
||||
name: t.String(),
|
||||
email: t.String(),
|
||||
phone: t.String(),
|
||||
company: t.String(),
|
||||
address: t.String(),
|
||||
status: t.Union([
|
||||
t.Literal("active"),
|
||||
t.Literal("inactive"),
|
||||
t.Literal("pending"),
|
||||
]),
|
||||
createdAt: t.String(),
|
||||
updatedAt: t.String(),
|
||||
}),
|
||||
),
|
||||
count: t.Number(),
|
||||
message: t.Optional(t.String()),
|
||||
}),
|
||||
};
|
||||
|
||||
// Export types from schemas
|
||||
export type Customer = typeof CustomerModel.Customer.static;
|
||||
export type CreateCustomer = typeof CustomerModel.CreateCustomer.static;
|
||||
export type UpdateCustomer = typeof CustomerModel.UpdateCustomer.static;
|
||||
export type CustomerList = typeof CustomerModel.CustomerList.static;
|
||||
109
src/modules/customers/service.ts
Normal file
109
src/modules/customers/service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { getCustomersByBranch, getCustomerById } from "@/lib/mock-data";
|
||||
import type { Customer, CreateCustomer, UpdateCustomer } from "./model";
|
||||
|
||||
/**
|
||||
* Get all customers for a specific branch
|
||||
* @param branch - Branch identifier
|
||||
* @param status - Optional status filter
|
||||
* @returns Array of customers
|
||||
*/
|
||||
export function getAllCustomers(
|
||||
branch: string,
|
||||
status?: "active" | "inactive" | "pending",
|
||||
): Customer[] {
|
||||
let customers = getCustomersByBranch(branch);
|
||||
|
||||
if (status) {
|
||||
customers = customers.filter((customer) => customer.status === status);
|
||||
}
|
||||
|
||||
return customers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single customer by ID and branch
|
||||
* @param branch - Branch identifier
|
||||
* @param id - Customer ID
|
||||
* @returns Customer or undefined if not found
|
||||
*/
|
||||
export function getCustomerByIdAndBranch(
|
||||
branch: string,
|
||||
id: string,
|
||||
): Customer | undefined {
|
||||
const customer = getCustomerById(id);
|
||||
|
||||
// Only return if customer belongs to the specified branch
|
||||
if (customer && customer.branch === branch) {
|
||||
return customer;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new customer
|
||||
* @param data - Customer creation data
|
||||
* @returns Newly created customer
|
||||
*/
|
||||
export function createCustomer(data: CreateCustomer): Customer {
|
||||
const newCustomer: Customer = {
|
||||
id: `cust-${Date.now()}`,
|
||||
...data,
|
||||
status: data.status || "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// In a real app, this would save to database
|
||||
// For now, we'll just return the new customer
|
||||
return newCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing customer
|
||||
* @param branch - Branch identifier
|
||||
* @param id - Customer ID
|
||||
* @param data - Customer update data
|
||||
* @returns Updated customer or undefined if not found
|
||||
*/
|
||||
export function updateCustomer(
|
||||
branch: string,
|
||||
id: string,
|
||||
data: UpdateCustomer,
|
||||
): Customer | undefined {
|
||||
const customer = getCustomerByIdAndBranch(branch, id);
|
||||
|
||||
if (!customer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Merge update data
|
||||
const updatedCustomer: Customer = {
|
||||
...customer,
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// In a real app, this would update database
|
||||
return updatedCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a customer
|
||||
* @param branch - Branch identifier
|
||||
* @param id - Customer ID
|
||||
* @returns Deleted customer or undefined if not found
|
||||
*/
|
||||
export function deleteCustomer(
|
||||
branch: string,
|
||||
id: string,
|
||||
): Customer | undefined {
|
||||
const customer = getCustomerByIdAndBranch(branch, id);
|
||||
|
||||
if (!customer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// In a real app, this would delete from database
|
||||
return customer;
|
||||
}
|
||||
224
src/modules/quotations/controller.ts
Normal file
224
src/modules/quotations/controller.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import * as service from "./service";
|
||||
import { QuotationModel } from "./model";
|
||||
|
||||
// Create Elysia instance for quotations module
|
||||
export const quotations = new Elysia({
|
||||
prefix: "/quotations",
|
||||
tags: ["quotations"],
|
||||
})
|
||||
.model(QuotationModel)
|
||||
// GET /api/quotations/:branch - Get all quotations by branch
|
||||
.get(
|
||||
"/:branch",
|
||||
({ params, query }) => {
|
||||
const { branch } = params;
|
||||
const { status } = query as { status?: string };
|
||||
|
||||
const quotations = service.getAllQuotations(
|
||||
branch,
|
||||
status as
|
||||
| "draft"
|
||||
| "sent"
|
||||
| "accepted"
|
||||
| "rejected"
|
||||
| "expired"
|
||||
| undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: quotations,
|
||||
count: quotations.length,
|
||||
message: `Found ${quotations.length} quotation(s) for branch: ${branch}`,
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
}),
|
||||
query: t.Optional(
|
||||
t.Object({
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("draft"),
|
||||
t.Literal("sent"),
|
||||
t.Literal("accepted"),
|
||||
t.Literal("rejected"),
|
||||
t.Literal("expired"),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
),
|
||||
response: QuotationModel.QuotationList,
|
||||
detail: {
|
||||
description: "Get all quotations for a specific branch",
|
||||
parameters: [
|
||||
{
|
||||
name: "branch",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Branch identifier (e.g., branch-01, branch-02, head-office)",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: {
|
||||
type: "string",
|
||||
enum: ["draft", "sent", "accepted", "rejected", "expired"],
|
||||
},
|
||||
description: "Filter quotations by status",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
// GET /api/quotations/:branch/:id - Get single quotation by ID
|
||||
.get(
|
||||
"/:branch/:id",
|
||||
({ params }) => {
|
||||
const { branch, id } = params;
|
||||
const quotation = service.getQuotationByIdAndBranch(branch, id);
|
||||
|
||||
if (!quotation) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Quotation not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: quotation,
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
id: t.String(),
|
||||
}),
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
success: t.Literal(true),
|
||||
data: QuotationModel.Quotation,
|
||||
}),
|
||||
t.Object({
|
||||
success: t.Literal(false),
|
||||
error: t.String(),
|
||||
}),
|
||||
]),
|
||||
detail: {
|
||||
description: "Get a single quotation by ID and branch",
|
||||
},
|
||||
},
|
||||
)
|
||||
// POST /api/quotations - Create new quotation
|
||||
.post(
|
||||
"/",
|
||||
({ body }) => {
|
||||
const quotation = service.createQuotation(body);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: quotation,
|
||||
message: "Quotation created successfully",
|
||||
};
|
||||
},
|
||||
{
|
||||
body: QuotationModel.CreateQuotation,
|
||||
response: t.Object({
|
||||
success: t.Boolean(),
|
||||
data: QuotationModel.Quotation,
|
||||
message: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
description: "Create a new quotation",
|
||||
},
|
||||
},
|
||||
)
|
||||
// PUT /api/quotations/:branch/:id - Update quotation
|
||||
.put(
|
||||
"/:branch/:id",
|
||||
({ params, body }) => {
|
||||
const { branch, id } = params;
|
||||
const quotation = service.updateQuotation(branch, id, body);
|
||||
|
||||
if (!quotation) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Quotation not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: quotation,
|
||||
message: "Quotation updated successfully",
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
id: t.String(),
|
||||
}),
|
||||
body: QuotationModel.UpdateQuotation,
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
success: t.Literal(true),
|
||||
data: QuotationModel.Quotation,
|
||||
message: t.String(),
|
||||
}),
|
||||
t.Object({
|
||||
success: t.Literal(false),
|
||||
error: t.String(),
|
||||
}),
|
||||
]),
|
||||
detail: {
|
||||
description: "Update an existing quotation",
|
||||
},
|
||||
},
|
||||
)
|
||||
// DELETE /api/quotations/:branch/:id - Delete quotation
|
||||
.delete(
|
||||
"/:branch/:id",
|
||||
({ params }) => {
|
||||
const { branch, id } = params;
|
||||
const quotation = service.deleteQuotation(branch, id);
|
||||
|
||||
if (!quotation) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Quotation not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: quotation,
|
||||
message: "Quotation deleted successfully",
|
||||
};
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
branch: t.String(),
|
||||
id: t.String(),
|
||||
}),
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
success: t.Literal(true),
|
||||
data: QuotationModel.Quotation,
|
||||
message: t.String(),
|
||||
}),
|
||||
t.Object({
|
||||
success: t.Literal(false),
|
||||
error: t.String(),
|
||||
}),
|
||||
]),
|
||||
detail: {
|
||||
description: "Delete a quotation",
|
||||
},
|
||||
},
|
||||
);
|
||||
104
src/modules/quotations/model.ts
Normal file
104
src/modules/quotations/model.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { t } from "elysia";
|
||||
|
||||
// Schemas for validation
|
||||
export const QuotationModel = {
|
||||
Quotation: t.Object({
|
||||
id: t.String(),
|
||||
quotationNumber: t.String(),
|
||||
branch: t.String(),
|
||||
customerId: t.String(),
|
||||
customerName: t.String(),
|
||||
date: t.String({ format: "date-time" }),
|
||||
validUntil: t.String({ format: "date-time" }),
|
||||
subtotal: t.Number(),
|
||||
taxRate: t.Number(),
|
||||
taxAmount: t.Number(),
|
||||
totalAmount: t.Number(),
|
||||
status: t.Union([
|
||||
t.Literal("draft"),
|
||||
t.Literal("sent"),
|
||||
t.Literal("accepted"),
|
||||
t.Literal("rejected"),
|
||||
t.Literal("expired"),
|
||||
]),
|
||||
notes: t.Optional(t.String()),
|
||||
createdAt: t.String({ format: "date-time" }),
|
||||
updatedAt: t.String({ format: "date-time" }),
|
||||
}),
|
||||
|
||||
CreateQuotation: t.Object({
|
||||
branch: t.String(),
|
||||
customerId: t.String(),
|
||||
customerName: t.String(),
|
||||
date: t.String({ format: "date-time" }),
|
||||
validUntil: t.String({ format: "date-time" }),
|
||||
subtotal: t.Number(),
|
||||
taxRate: t.Number(),
|
||||
notes: t.Optional(t.String()),
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("draft"),
|
||||
t.Literal("sent"),
|
||||
t.Literal("accepted"),
|
||||
t.Literal("rejected"),
|
||||
t.Literal("expired"),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
|
||||
UpdateQuotation: t.Object({
|
||||
customerId: t.Optional(t.String()),
|
||||
customerName: t.Optional(t.String()),
|
||||
date: t.Optional(t.String({ format: "date-time" })),
|
||||
validUntil: t.Optional(t.String({ format: "date-time" })),
|
||||
subtotal: t.Optional(t.Number()),
|
||||
taxRate: t.Optional(t.Number()),
|
||||
notes: t.Optional(t.String()),
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("draft"),
|
||||
t.Literal("sent"),
|
||||
t.Literal("accepted"),
|
||||
t.Literal("rejected"),
|
||||
t.Literal("expired"),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
|
||||
QuotationList: t.Object({
|
||||
success: t.Boolean(),
|
||||
data: t.Array(
|
||||
t.Object({
|
||||
id: t.String(),
|
||||
quotationNumber: t.String(),
|
||||
branch: t.String(),
|
||||
customerId: t.String(),
|
||||
customerName: t.String(),
|
||||
date: t.String(),
|
||||
validUntil: t.String(),
|
||||
subtotal: t.Number(),
|
||||
taxRate: t.Number(),
|
||||
taxAmount: t.Number(),
|
||||
totalAmount: t.Number(),
|
||||
status: t.Union([
|
||||
t.Literal("draft"),
|
||||
t.Literal("sent"),
|
||||
t.Literal("accepted"),
|
||||
t.Literal("rejected"),
|
||||
t.Literal("expired"),
|
||||
]),
|
||||
notes: t.Optional(t.String()),
|
||||
createdAt: t.String(),
|
||||
updatedAt: t.String(),
|
||||
}),
|
||||
),
|
||||
count: t.Number(),
|
||||
message: t.Optional(t.String()),
|
||||
}),
|
||||
};
|
||||
|
||||
// Export types from schemas
|
||||
export type Quotation = typeof QuotationModel.Quotation.static;
|
||||
export type CreateQuotation = typeof QuotationModel.CreateQuotation.static;
|
||||
export type UpdateQuotation = typeof QuotationModel.UpdateQuotation.static;
|
||||
export type QuotationList = typeof QuotationModel.QuotationList.static;
|
||||
222
src/modules/quotations/service.ts
Normal file
222
src/modules/quotations/service.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import type { Quotation, CreateQuotation, UpdateQuotation } from "./model";
|
||||
|
||||
// Mock quotations data
|
||||
const mockQuotations: Quotation[] = [
|
||||
{
|
||||
id: "quot-001",
|
||||
quotationNumber: "QT-2024-001",
|
||||
branch: "branch-01",
|
||||
customerId: "cust-001",
|
||||
customerName: "สมชาย ใจดี",
|
||||
date: "2024-01-20T00:00:00Z",
|
||||
validUntil: "2024-02-20T00:00:00Z",
|
||||
subtotal: 50000,
|
||||
taxRate: 0.07,
|
||||
taxAmount: 3500,
|
||||
totalAmount: 53500,
|
||||
status: "sent",
|
||||
notes: "Quotation for office supplies",
|
||||
createdAt: "2024-01-20T09:00:00Z",
|
||||
updatedAt: "2024-01-20T09:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "quot-002",
|
||||
quotationNumber: "QT-2024-002",
|
||||
branch: "branch-01",
|
||||
customerId: "cust-002",
|
||||
customerName: "วิภา สุขสันต์",
|
||||
date: "2024-02-25T00:00:00Z",
|
||||
validUntil: "2024-03-25T00:00:00Z",
|
||||
subtotal: 120000,
|
||||
taxRate: 0.07,
|
||||
taxAmount: 8400,
|
||||
totalAmount: 128400,
|
||||
status: "accepted",
|
||||
notes: "Quotation for computer equipment",
|
||||
createdAt: "2024-02-25T10:30:00Z",
|
||||
updatedAt: "2024-02-28T14:20:00Z",
|
||||
},
|
||||
{
|
||||
id: "quot-003",
|
||||
quotationNumber: "QT-2024-003",
|
||||
branch: "branch-02",
|
||||
customerId: "cust-004",
|
||||
customerName: "มานี มีสุข",
|
||||
date: "2024-03-10T00:00:00Z",
|
||||
validUntil: "2024-04-10T00:00:00Z",
|
||||
subtotal: 75000,
|
||||
taxRate: 0.07,
|
||||
taxAmount: 5250,
|
||||
totalAmount: 80250,
|
||||
status: "draft",
|
||||
notes: null,
|
||||
createdAt: "2024-03-10T11:00:00Z",
|
||||
updatedAt: "2024-03-10T11:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "quot-004",
|
||||
quotationNumber: "QT-2024-004",
|
||||
branch: "head-office",
|
||||
customerId: "cust-007",
|
||||
customerName: "ภูมิ รักษ์โลก",
|
||||
date: "2024-04-01T00:00:00Z",
|
||||
validUntil: "2024-05-01T00:00:00Z",
|
||||
subtotal: 200000,
|
||||
taxRate: 0.07,
|
||||
taxAmount: 14000,
|
||||
totalAmount: 214000,
|
||||
status: "sent",
|
||||
notes: "Quotation for laboratory equipment",
|
||||
createdAt: "2024-04-01T09:30:00Z",
|
||||
updatedAt: "2024-04-01T09:30:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all quotations for a specific branch
|
||||
* @param branch - Branch identifier
|
||||
* @param status - Optional status filter
|
||||
* @returns Array of quotations
|
||||
*/
|
||||
export function getAllQuotations(
|
||||
branch: string,
|
||||
status?: "draft" | "sent" | "accepted" | "rejected" | "expired",
|
||||
): Quotation[] {
|
||||
let quotations = mockQuotations.filter((q) => q.branch === branch);
|
||||
|
||||
if (status) {
|
||||
quotations = quotations.filter((q) => q.status === status);
|
||||
}
|
||||
|
||||
return quotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single quotation by ID and branch
|
||||
* @param branch - Branch identifier
|
||||
* @param id - Quotation ID
|
||||
* @returns Quotation or undefined if not found
|
||||
*/
|
||||
export function getQuotationByIdAndBranch(
|
||||
branch: string,
|
||||
id: string,
|
||||
): Quotation | undefined {
|
||||
const quotation = mockQuotations.find((q) => q.id === id);
|
||||
|
||||
// Only return if quotation belongs to the specified branch
|
||||
if (quotation && quotation.branch === branch) {
|
||||
return quotation;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tax and total amounts
|
||||
* @param subtotal - Subtotal amount
|
||||
* @param taxRate - Tax rate (e.g., 0.07 for 7%)
|
||||
* @returns Object with taxAmount and totalAmount
|
||||
*/
|
||||
function calculateTotals(subtotal: number, taxRate: number) {
|
||||
const taxAmount = subtotal * taxRate;
|
||||
const totalAmount = subtotal + taxAmount;
|
||||
return { taxAmount, totalAmount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new quotation
|
||||
* @param data - Quotation creation data
|
||||
* @returns Newly created quotation
|
||||
*/
|
||||
export function createQuotation(data: CreateQuotation): Quotation {
|
||||
const { taxAmount, totalAmount } = calculateTotals(
|
||||
data.subtotal,
|
||||
data.taxRate,
|
||||
);
|
||||
|
||||
const newQuotation: Quotation = {
|
||||
id: `quot-${Date.now()}`,
|
||||
quotationNumber: `QT-${new Date().getFullYear()}-${String(mockQuotations.length + 1).padStart(3, "0")}`,
|
||||
...data,
|
||||
taxAmount,
|
||||
totalAmount,
|
||||
status: data.status || "draft",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// In a real app, this would save to database
|
||||
mockQuotations.push(newQuotation);
|
||||
return newQuotation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing quotation
|
||||
* @param branch - Branch identifier
|
||||
* @param id - Quotation ID
|
||||
* @param data - Quotation update data
|
||||
* @returns Updated quotation or undefined if not found
|
||||
*/
|
||||
export function updateQuotation(
|
||||
branch: string,
|
||||
id: string,
|
||||
data: UpdateQuotation,
|
||||
): Quotation | undefined {
|
||||
const quotation = getQuotationByIdAndBranch(branch, id);
|
||||
|
||||
if (!quotation) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Recalculate totals if subtotal or taxRate changed
|
||||
let { taxAmount, totalAmount } = quotation;
|
||||
if (data.subtotal !== undefined || data.taxRate !== undefined) {
|
||||
const newSubtotal = data.subtotal ?? quotation.subtotal;
|
||||
const newTaxRate = data.taxRate ?? quotation.taxRate;
|
||||
const calculated = calculateTotals(newSubtotal, newTaxRate);
|
||||
taxAmount = calculated.taxAmount;
|
||||
totalAmount = calculated.totalAmount;
|
||||
}
|
||||
|
||||
// Merge update data
|
||||
const updatedQuotation: Quotation = {
|
||||
...quotation,
|
||||
...data,
|
||||
taxAmount,
|
||||
totalAmount,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// In a real app, this would update database
|
||||
const index = mockQuotations.findIndex((q) => q.id === id);
|
||||
if (index !== -1) {
|
||||
mockQuotations[index] = updatedQuotation;
|
||||
}
|
||||
|
||||
return updatedQuotation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a quotation
|
||||
* @param branch - Branch identifier
|
||||
* @param id - Quotation ID
|
||||
* @returns Deleted quotation or undefined if not found
|
||||
*/
|
||||
export function deleteQuotation(
|
||||
branch: string,
|
||||
id: string,
|
||||
): Quotation | undefined {
|
||||
const quotation = getQuotationByIdAndBranch(branch, id);
|
||||
|
||||
if (!quotation) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// In a real app, this would delete from database
|
||||
const index = mockQuotations.findIndex((q) => q.id === id);
|
||||
if (index !== -1) {
|
||||
mockQuotations.splice(index, 1);
|
||||
}
|
||||
|
||||
return quotation;
|
||||
}
|
||||
86
src/providers/AuthProvider.tsx
Normal file
86
src/providers/AuthProvider.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
initKeycloak,
|
||||
logout as keycloakLogout,
|
||||
getUserInfo,
|
||||
isAuthenticated as isKeycloakAuthenticated,
|
||||
} from "@/lib/keycloak-client";
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
userInfo: any;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [userInfo, setUserInfo] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function initAuth() {
|
||||
try {
|
||||
const authenticated = await initKeycloak();
|
||||
|
||||
if (mounted) {
|
||||
setIsAuthenticated(authenticated);
|
||||
setIsLoading(false);
|
||||
if (authenticated) {
|
||||
setUserInfo(getUserInfo());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Auth initialization failed:", error);
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initAuth();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await keycloakLogout();
|
||||
setIsAuthenticated(false);
|
||||
setUserInfo(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
userInfo,
|
||||
logout: handleLogout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
31
src/types/customer.ts
Normal file
31
src/types/customer.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export interface Customer {
|
||||
id: string;
|
||||
branch: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
address: string;
|
||||
status: "active" | "inactive" | "pending";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateCustomerInput {
|
||||
branch: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
address: string;
|
||||
status?: "active" | "inactive" | "pending";
|
||||
}
|
||||
|
||||
export interface UpdateCustomerInput {
|
||||
name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
address?: string;
|
||||
status?: "active" | "inactive" | "pending";
|
||||
}
|
||||
Reference in New Issue
Block a user