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": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
|
"@elysiajs/eden": "^1.4.9",
|
||||||
"@hugeicons/core-free-icons": "^4.1.1",
|
"@hugeicons/core-free-icons": "^4.1.1",
|
||||||
"@hugeicons/react": "^1.1.6",
|
"@hugeicons/react": "^1.1.6",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
@@ -20,15 +21,22 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"elysia": "^1.4.28",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"jose": "^6.2.2",
|
||||||
"kbar": "^0.1.0-beta.48",
|
"kbar": "^0.1.0-beta.48",
|
||||||
|
"keycloak": "^1.2.0",
|
||||||
|
"keycloak-js": "^26.2.4",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
"nuqs": "^2.8.9",
|
"nuqs": "^2.8.9",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
@@ -47,12 +55,15 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"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 KBar from "@/components/kbar";
|
||||||
import AppSidebar from '@/components/layout/app-sidebar';
|
import AppSidebar from "@/components/layout/app-sidebar";
|
||||||
import Header from '@/components/layout/header';
|
import Header from "@/components/layout/header";
|
||||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from "next";
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Next Shadcn Dashboard Starter',
|
title: "Admin",
|
||||||
description: 'Basic dashboard with Next.js and Shadcn'
|
description: "Admin",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
// Persisting the sidebar state in the cookie.
|
// Persisting the sidebar state in the cookie.
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true"
|
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true";
|
||||||
return (
|
return (
|
||||||
<KBar>
|
<KBar>
|
||||||
<SidebarProvider defaultOpen={defaultOpen}>
|
<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 { Toaster } from "@/components/ui/sonner";
|
||||||
import { fontVariables } from "@/lib/font";
|
import { fontVariables } from "@/lib/font";
|
||||||
import ThemeProvider from "@/components/layout/ThemeToggle/theme-provider";
|
import ThemeProvider from "@/components/layout/ThemeToggle/theme-provider";
|
||||||
|
import { AuthProvider } from "@/providers/AuthProvider";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
@@ -16,8 +17,8 @@ const META_THEME_COLORS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Next Shadcn",
|
title: "ALLA-OS",
|
||||||
description: "Basic dashboard with Next.js and Shadcn",
|
description: "ALLA-OS [order-system]",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
@@ -65,10 +66,12 @@ export default async function RootLayout({
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
enableColorScheme
|
enableColorScheme
|
||||||
>
|
>
|
||||||
<Providers activeThemeValue={activeThemeValue as string}>
|
<AuthProvider>
|
||||||
<Toaster />
|
<Providers activeThemeValue={activeThemeValue as string}>
|
||||||
{children}
|
<Toaster />
|
||||||
</Providers>
|
{children}
|
||||||
|
</Providers>
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</NuqsAdapter>
|
</NuqsAdapter>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
redirect("/admin/overview");
|
redirect("/alla/customers");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
//import { UserAvatarProfile } from "@/components/user-avatar-profile";
|
//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 { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -47,31 +47,36 @@ import { usePathname, useRouter } from "next/navigation";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Icons } from "../icons";
|
import { Icons } from "../icons";
|
||||||
import { OrgSwitcher } from "../org-switcher";
|
import { OrgSwitcher } from "../org-switcher";
|
||||||
|
import { useAuth } from "@/providers/AuthProvider";
|
||||||
export const company = {
|
export const company = {
|
||||||
name: "Acme Inc",
|
name: "ALLA",
|
||||||
logo: IconPhotoUp,
|
logo: IconPhotoUp,
|
||||||
plan: "Enterprise",
|
plan: "Enterprise",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tenants = [
|
const tenants = [
|
||||||
{ id: "1", name: "Acme Inc" },
|
{ id: "1", name: "ALLA" },
|
||||||
{ id: "2", name: "Beta Corp" },
|
{ id: "2", name: "ONVALLA" },
|
||||||
{ id: "3", name: "Gamma Ltd" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AppSidebar() {
|
export default function AppSidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isOpen } = useMediaQuery();
|
const { isOpen } = useMediaQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const handleSwitchTenant = (_tenantId: string) => {
|
const [activeTenant, setActiveTenant] = React.useState(tenants[0]);
|
||||||
// Tenant switching functionality would be implemented here
|
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];
|
// Get navItems based on active tenant
|
||||||
|
const currentNavItems = tenantNavConfig[activeTenant.id] || navItems;
|
||||||
React.useEffect(() => {
|
|
||||||
// Side effects based on sidebar state changes
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
@@ -86,7 +91,7 @@ export default function AppSidebar() {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Overview</SidebarGroupLabel>
|
<SidebarGroupLabel>Overview</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{navItems.map((item) => {
|
{currentNavItems.map((item) => {
|
||||||
const Icon = item.icon ? Icons[item.icon] : Icons.logo;
|
const Icon = item.icon ? Icons[item.icon] : Icons.logo;
|
||||||
return item?.items && item?.items?.length > 0 ? (
|
return item?.items && item?.items?.length > 0 ? (
|
||||||
<Collapsible
|
<Collapsible
|
||||||
@@ -151,13 +156,18 @@ export default function AppSidebar() {
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
{/* {user && (
|
{userInfo && (
|
||||||
<UserAvatarProfile
|
<div className="flex items-center gap-2">
|
||||||
className='h-8 w-8 rounded-lg'
|
<IconUserCircle className="h-8 w-8" />
|
||||||
showInfo
|
<div className="flex flex-col text-center">
|
||||||
user={user}
|
<span className="text-sm ">
|
||||||
/>
|
{userInfo?.name ||
|
||||||
)} */}
|
userInfo?.preferred_username ||
|
||||||
|
"User"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<IconChevronsDown className="ml-auto size-4" />
|
<IconChevronsDown className="ml-auto size-4" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -168,38 +178,33 @@ export default function AppSidebar() {
|
|||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel className="p-0 font-normal">
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
<div className="px-1 py-1.5">
|
<div className="flex items-center gap-3 px-1 py-1.5 text-left text-sm">
|
||||||
{/* {user && (
|
{userInfo && (
|
||||||
<UserAvatarProfile
|
<>
|
||||||
className='h-8 w-8 rounded-lg'
|
<IconUserCircle className="h-8 w-8 rounded-lg bg-muted" />
|
||||||
showInfo
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
user={user}
|
<span className="truncate font-semibold">
|
||||||
/>
|
{userInfo?.name ||
|
||||||
)} */}
|
userInfo?.preferred_username ||
|
||||||
|
"User"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuGroup>
|
<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>
|
<DropdownMenuItem>
|
||||||
<IconBell className="mr-2 h-4 w-4" />
|
<IconBell className="mr-2 h-4 w-4" />
|
||||||
Notifications
|
Notifications
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem onClick={() => logout()}>
|
||||||
<IconLogout className="mr-2 h-4 w-4" />
|
<IconLogout className="mr-2 h-4 w-4" />
|
||||||
{/* <SignOutButton redirectUrl='/auth/sign-in' /> */}
|
Logout
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { SidebarTrigger } from '../ui/sidebar';
|
import { SidebarTrigger } from "../ui/sidebar";
|
||||||
import { Separator } from '../ui/separator';
|
import { Separator } from "../ui/separator";
|
||||||
import { Breadcrumbs } from '../breadcrumbs';
|
import { Breadcrumbs } from "../breadcrumbs";
|
||||||
import SearchInput from '../search-input';
|
|
||||||
import { UserNav } from './user-nav';
|
import { UserNav } from "./user-nav";
|
||||||
import { ThemeSelector } from '../theme-selector';
|
import { ThemeSelector } from "../theme-selector";
|
||||||
import { ModeToggle } from './ThemeToggle/theme-toggle';
|
import { ModeToggle } from "./ThemeToggle/theme-toggle";
|
||||||
import CtaGithub from './cta-github';
|
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
return (
|
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'>
|
<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'>
|
<div className="flex items-center gap-2 px-4">
|
||||||
<SidebarTrigger className='-ml-1' />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation='vertical' className='mr-2 h-4' />
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
<Breadcrumbs />
|
<Breadcrumbs />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center gap-2 px-4'>
|
<div className="flex items-center gap-2 px-4">
|
||||||
<CtaGithub />
|
|
||||||
<div className='hidden md:flex'>
|
|
||||||
<SearchInput />
|
|
||||||
</div>
|
|
||||||
<UserNav />
|
<UserNav />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<ThemeSelector />
|
<ThemeSelector />
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { Check, ChevronsUpDown, GalleryVerticalEnd } from 'lucide-react';
|
import { Check, ChevronsUpDown, GalleryVerticalEnd } from "lucide-react";
|
||||||
import * as React from 'react';
|
import * as React from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem
|
SidebarMenuItem,
|
||||||
} from '@/components/ui/sidebar';
|
} from "@/components/ui/sidebar";
|
||||||
|
|
||||||
interface Tenant {
|
interface Tenant {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,7 +23,7 @@ interface Tenant {
|
|||||||
export function OrgSwitcher({
|
export function OrgSwitcher({
|
||||||
tenants,
|
tenants,
|
||||||
defaultTenant,
|
defaultTenant,
|
||||||
onTenantSwitch
|
onTenantSwitch,
|
||||||
}: {
|
}: {
|
||||||
tenants: Tenant[];
|
tenants: Tenant[];
|
||||||
defaultTenant: Tenant;
|
defaultTenant: Tenant;
|
||||||
@@ -49,31 +49,31 @@ export function OrgSwitcher({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
size='lg'
|
size="lg"
|
||||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
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'>
|
<div className="bg-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||||
<GalleryVerticalEnd className='size-4' />
|
<GalleryVerticalEnd className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col gap-0.5 leading-none'>
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
<span className='font-semibold'>Next Starter</span>
|
<span className="font-semibold">ALLA OS</span>
|
||||||
<span className=''>{selectedTenant.name}</span>
|
<span className="">{selectedTenant.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown className='ml-auto' />
|
<ChevronsUpDown className="ml-auto" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className='w-[--radix-dropdown-menu-trigger-width]'
|
className="w-[--radix-dropdown-menu-trigger-width]"
|
||||||
align='start'
|
align="start"
|
||||||
>
|
>
|
||||||
{tenants.map((tenant) => (
|
{tenants.map((tenant) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={tenant.id}
|
key={tenant.id}
|
||||||
onSelect={() => handleTenantSwitch(tenant)}
|
onSelect={() => handleTenantSwitch(tenant)}
|
||||||
>
|
>
|
||||||
{tenant.name}{' '}
|
{tenant.name}{" "}
|
||||||
{tenant.id === selectedTenant.id && (
|
{tenant.id === selectedTenant.id && (
|
||||||
<Check className='ml-auto' />
|
<Check className="ml-auto" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NavGroup } from '@/types';
|
import { NavGroup } from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation configuration with RBAC support
|
* Navigation configuration with RBAC support
|
||||||
@@ -35,163 +35,163 @@ import { NavGroup } from '@/types';
|
|||||||
*/
|
*/
|
||||||
export const navGroups: NavGroup[] = [
|
export const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
label: 'Overview',
|
label: "Overview",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Dashboard',
|
title: "Dashboard",
|
||||||
url: '/dashboard/overview',
|
url: "/dashboard/overview",
|
||||||
icon: 'dashboard',
|
icon: "dashboard",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
shortcut: ['d', 'd'],
|
shortcut: ["d", "d"],
|
||||||
items: []
|
items: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Workspaces',
|
title: "Workspaces",
|
||||||
url: '/dashboard/workspaces',
|
url: "/dashboard/workspaces",
|
||||||
icon: 'workspace',
|
icon: "workspace",
|
||||||
isActive: false,
|
|
||||||
items: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Teams',
|
|
||||||
url: '/dashboard/workspaces/team',
|
|
||||||
icon: 'teams',
|
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Product',
|
title: "Teams",
|
||||||
url: '/dashboard/product',
|
url: "/dashboard/workspaces/team",
|
||||||
icon: 'product',
|
icon: "teams",
|
||||||
shortcut: ['p', 'p'],
|
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: []
|
items: [],
|
||||||
|
access: { requireOrg: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Users',
|
title: "Product",
|
||||||
url: '/dashboard/users',
|
url: "/dashboard/product",
|
||||||
icon: 'teams',
|
icon: "product",
|
||||||
shortcut: ['u', 'u'],
|
shortcut: ["p", "p"],
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: []
|
items: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Kanban',
|
title: "Users",
|
||||||
url: '/dashboard/kanban',
|
url: "/dashboard/users",
|
||||||
icon: 'kanban',
|
icon: "teams",
|
||||||
shortcut: ['k', 'k'],
|
shortcut: ["u", "u"],
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: []
|
items: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Chat',
|
title: "Kanban",
|
||||||
url: '/dashboard/chat',
|
url: "/dashboard/kanban",
|
||||||
icon: 'chat',
|
icon: "kanban",
|
||||||
shortcut: ['c', 'c'],
|
shortcut: ["k", "k"],
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: []
|
items: [],
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
|
title: "Chat",
|
||||||
|
url: "/dashboard/chat",
|
||||||
|
icon: "chat",
|
||||||
|
shortcut: ["c", "c"],
|
||||||
|
isActive: false,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Elements',
|
label: "Elements",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Forms',
|
title: "Forms",
|
||||||
url: '#',
|
url: "#",
|
||||||
icon: 'forms',
|
icon: "forms",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Basic Form',
|
title: "Basic Form",
|
||||||
url: '/dashboard/forms/basic',
|
url: "/dashboard/forms/basic",
|
||||||
icon: 'forms',
|
icon: "forms",
|
||||||
shortcut: ['f', 'f']
|
shortcut: ["f", "f"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Multi-Step Form',
|
title: "Multi-Step Form",
|
||||||
url: '/dashboard/forms/multi-step',
|
url: "/dashboard/forms/multi-step",
|
||||||
icon: 'forms'
|
icon: "forms",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Sheet & Dialog',
|
title: "Sheet & Dialog",
|
||||||
url: '/dashboard/forms/sheet-form',
|
url: "/dashboard/forms/sheet-form",
|
||||||
icon: 'forms'
|
icon: "forms",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Advanced Patterns',
|
title: "Advanced Patterns",
|
||||||
url: '/dashboard/forms/advanced',
|
url: "/dashboard/forms/advanced",
|
||||||
icon: 'forms'
|
icon: "forms",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'React Query',
|
title: "React Query",
|
||||||
url: '/dashboard/react-query',
|
url: "/dashboard/react-query",
|
||||||
icon: 'code',
|
icon: "code",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: []
|
items: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Icons',
|
title: "Icons",
|
||||||
url: '/dashboard/elements/icons',
|
url: "/dashboard/elements/icons",
|
||||||
icon: 'palette',
|
icon: "palette",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: []
|
items: [],
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '',
|
label: "",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Pro',
|
title: "Pro",
|
||||||
url: '#',
|
url: "#",
|
||||||
icon: 'pro',
|
icon: "pro",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Exclusive',
|
title: "Exclusive",
|
||||||
url: '/dashboard/exclusive',
|
url: "/dashboard/exclusive",
|
||||||
icon: 'exclusive',
|
icon: "exclusive",
|
||||||
shortcut: ['e', 'e']
|
shortcut: ["e", "e"],
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Account',
|
title: "Account",
|
||||||
url: '#',
|
url: "#",
|
||||||
icon: 'account',
|
icon: "account",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Profile',
|
title: "Profile",
|
||||||
url: '/dashboard/profile',
|
url: "/dashboard/profile",
|
||||||
icon: 'profile',
|
icon: "profile",
|
||||||
shortcut: ['m', 'm']
|
shortcut: ["m", "m"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Notifications',
|
title: "Notifications",
|
||||||
url: '/dashboard/notifications',
|
url: "/dashboard/notifications",
|
||||||
icon: 'notification',
|
icon: "notification",
|
||||||
shortcut: ['n', 'n']
|
shortcut: ["n", "n"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Billing',
|
title: "Billing",
|
||||||
url: '/dashboard/billing',
|
url: "/dashboard/billing",
|
||||||
icon: 'billing',
|
icon: "billing",
|
||||||
shortcut: ['b', 'b'],
|
shortcut: ["b", "b"],
|
||||||
access: { requireOrg: true }
|
access: { requireOrg: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Login',
|
title: "Login",
|
||||||
shortcut: ['l', 'l'],
|
shortcut: ["l", "l"],
|
||||||
url: '/',
|
url: "/",
|
||||||
icon: 'login'
|
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 = {
|
export type Product = {
|
||||||
photo_url: string;
|
photo_url: string;
|
||||||
@@ -14,50 +52,19 @@ export type Product = {
|
|||||||
//Info: The following data is used for the sidebar navigation and Cmd K bar.
|
//Info: The following data is used for the sidebar navigation and Cmd K bar.
|
||||||
export const navItems: NavItem[] = [
|
export const navItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Dashboard',
|
title: "Dashboard",
|
||||||
url: '/dashboard/overview',
|
url: "/alla/dashboard/overview",
|
||||||
icon: 'dashboard',
|
icon: "dashboard",
|
||||||
isActive: false,
|
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',
|
title: "Customers",
|
||||||
url: '/dashboard/product',
|
url: "/alla/customers",
|
||||||
icon: 'product',
|
icon: "product",
|
||||||
shortcut: ['p', 'p'],
|
|
||||||
isActive: false,
|
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 {
|
export interface SaleUser {
|
||||||
@@ -72,42 +79,42 @@ export interface SaleUser {
|
|||||||
export const recentSalesData: SaleUser[] = [
|
export const recentSalesData: SaleUser[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Olivia Martin',
|
name: "Olivia Martin",
|
||||||
email: 'olivia.martin@email.com',
|
email: "olivia.martin@email.com",
|
||||||
amount: '+$1,999.00',
|
amount: "+$1,999.00",
|
||||||
image: 'https://api.slingacademy.com/public/sample-users/1.png',
|
image: "https://api.slingacademy.com/public/sample-users/1.png",
|
||||||
initials: 'OM'
|
initials: "OM",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'Jackson Lee',
|
name: "Jackson Lee",
|
||||||
email: 'jackson.lee@email.com',
|
email: "jackson.lee@email.com",
|
||||||
amount: '+$39.00',
|
amount: "+$39.00",
|
||||||
image: 'https://api.slingacademy.com/public/sample-users/2.png',
|
image: "https://api.slingacademy.com/public/sample-users/2.png",
|
||||||
initials: 'JL'
|
initials: "JL",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
name: 'Isabella Nguyen',
|
name: "Isabella Nguyen",
|
||||||
email: 'isabella.nguyen@email.com',
|
email: "isabella.nguyen@email.com",
|
||||||
amount: '+$299.00',
|
amount: "+$299.00",
|
||||||
image: 'https://api.slingacademy.com/public/sample-users/3.png',
|
image: "https://api.slingacademy.com/public/sample-users/3.png",
|
||||||
initials: 'IN'
|
initials: "IN",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
name: 'William Kim',
|
name: "William Kim",
|
||||||
email: 'will@email.com',
|
email: "will@email.com",
|
||||||
amount: '+$99.00',
|
amount: "+$99.00",
|
||||||
image: 'https://api.slingacademy.com/public/sample-users/4.png',
|
image: "https://api.slingacademy.com/public/sample-users/4.png",
|
||||||
initials: 'WK'
|
initials: "WK",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
name: 'Sofia Davis',
|
name: "Sofia Davis",
|
||||||
email: 'sofia.davis@email.com',
|
email: "sofia.davis@email.com",
|
||||||
amount: '+$39.00',
|
amount: "+$39.00",
|
||||||
image: 'https://api.slingacademy.com/public/sample-users/5.png',
|
image: "https://api.slingacademy.com/public/sample-users/5.png",
|
||||||
initials: 'SD'
|
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}`, {
|
const res = await fetch(`${BASE_URL}${endpoint}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
...options,
|
||||||
...options
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
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}`);
|
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