feat: implement naming convention, deployment automation, and infrastructure updates
- Add comprehensive naming convention (provider-region-resource-env-purpose) - Implement Terraform locals for centralized naming - Update all Terraform resources to use new naming convention - Create deployment automation framework (18 phase scripts) - Add Azure setup scripts (provider registration, quota checks) - Update deployment scripts config with naming functions - Create complete deployment documentation (guide, steps, quick reference) - Add frontend portal implementations (public and internal) - Add UI component library (18 components) - Enhance Entra VerifiedID integration with file utilities - Add API client package for all services - Create comprehensive documentation (naming, deployment, next steps) Infrastructure: - Resource groups, storage accounts with new naming - Terraform configuration updates - Outputs with naming convention examples Deployment: - Automated deployment scripts for all 15 phases - State management and logging - Error handling and validation Documentation: - Naming convention guide and implementation summary - Complete deployment guide (296 steps) - Next steps and quick start guides - Azure prerequisites and setup completion docs Note: ESLint warnings present - will be addressed in follow-up commit
This commit is contained in:
23
packages/api-client/package.json
Normal file
23
packages/api-client/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@the-order/api-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "API client library for The Order services",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"@the-order/schemas": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.6",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
53
packages/api-client/src/client.ts
Normal file
53
packages/api-client/src/client.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { IdentityClient } from './identity';
|
||||
import { EResidencyClient } from './eresidency';
|
||||
import { IntakeClient } from './intake';
|
||||
import { FinanceClient } from './finance';
|
||||
import { DataroomClient } from './dataroom';
|
||||
|
||||
export class ApiClient {
|
||||
public readonly identity: IdentityClient;
|
||||
public readonly eresidency: EResidencyClient;
|
||||
public readonly intake: IntakeClient;
|
||||
public readonly finance: FinanceClient;
|
||||
public readonly dataroom: DataroomClient;
|
||||
|
||||
constructor(baseURL?: string) {
|
||||
// Initialize service clients - each manages its own axios instance
|
||||
this.identity = new IdentityClient();
|
||||
this.eresidency = new EResidencyClient();
|
||||
this.intake = new IntakeClient();
|
||||
this.finance = new FinanceClient();
|
||||
this.dataroom = new DataroomClient();
|
||||
}
|
||||
|
||||
setAuthToken(token: string): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
// Update all service clients
|
||||
this.identity.setAuthToken(token);
|
||||
this.eresidency.setAuthToken(token);
|
||||
this.intake.setAuthToken(token);
|
||||
this.finance.setAuthToken(token);
|
||||
this.dataroom.setAuthToken(token);
|
||||
}
|
||||
|
||||
clearAuth(): void {
|
||||
// Clear tokens in all service clients
|
||||
this.identity.clearAuthToken();
|
||||
this.eresidency.clearAuthToken();
|
||||
this.intake.clearAuthToken();
|
||||
this.finance.clearAuthToken();
|
||||
this.dataroom.clearAuthToken();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let apiClientInstance: ApiClient | null = null;
|
||||
|
||||
export function getApiClient(): ApiClient {
|
||||
if (!apiClientInstance) {
|
||||
apiClientInstance = new ApiClient();
|
||||
}
|
||||
return apiClientInstance;
|
||||
}
|
||||
140
packages/api-client/src/dataroom.ts
Normal file
140
packages/api-client/src/dataroom.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
export interface DealRoom {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'active' | 'archived' | 'closed';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
participants: string[];
|
||||
documents: string[];
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: string;
|
||||
roomId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
uploadedBy: string;
|
||||
uploadedAt: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export class DataroomClient {
|
||||
protected client: AxiosInstance;
|
||||
|
||||
constructor(baseURL?: string) {
|
||||
const apiBaseURL =
|
||||
baseURL ||
|
||||
(typeof window !== 'undefined'
|
||||
? process.env.NEXT_PUBLIC_DATAROOM_SERVICE_URL || 'http://localhost:4006'
|
||||
: 'http://localhost:4006');
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: apiBaseURL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Set up request interceptor for authentication
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = this.getAuthToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
}
|
||||
|
||||
private getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
|
||||
setAuthToken(token: string): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
}
|
||||
|
||||
clearAuthToken(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
}
|
||||
|
||||
async createDealRoom(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
participants?: string[];
|
||||
}): Promise<DealRoom> {
|
||||
const response = await this.client.post<DealRoom>('/api/v1/deal-rooms', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getDealRoom(roomId: string): Promise<DealRoom> {
|
||||
const response = await this.client.get<DealRoom>(`/api/v1/deal-rooms/${roomId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async listDealRooms(filters?: {
|
||||
status?: string;
|
||||
participantId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ rooms: DealRoom[]; total: number }> {
|
||||
const response = await this.client.get<{ rooms: DealRoom[]; total: number }>(
|
||||
'/api/v1/deal-rooms',
|
||||
{ params: filters }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateDealRoom(roomId: string, data: Partial<DealRoom>): Promise<DealRoom> {
|
||||
const response = await this.client.patch<DealRoom>(`/api/v1/deal-rooms/${roomId}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async uploadDocument(roomId: string, file: File | Blob, metadata?: {
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
}): Promise<Document> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (metadata) {
|
||||
formData.append('category', metadata.category || '');
|
||||
if (metadata.tags) {
|
||||
formData.append('tags', JSON.stringify(metadata.tags));
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.client.post<Document>(
|
||||
`/api/v1/deal-rooms/${roomId}/documents`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async listDocuments(roomId: string): Promise<Document[]> {
|
||||
const response = await this.client.get<Document[]>(
|
||||
`/api/v1/deal-rooms/${roomId}/documents`
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteDocument(roomId: string, documentId: string): Promise<void> {
|
||||
await this.client.delete(`/api/v1/deal-rooms/${roomId}/documents/${documentId}`);
|
||||
}
|
||||
}
|
||||
89
packages/api-client/src/eresidency.ts
Normal file
89
packages/api-client/src/eresidency.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { ApiClient } from './client';
|
||||
import type { eResidencyApplication, ApplicationStatus } from '@the-order/schemas';
|
||||
|
||||
export interface SubmitApplicationRequest {
|
||||
email: string;
|
||||
givenName: string;
|
||||
familyName: string;
|
||||
dateOfBirth?: string;
|
||||
nationality?: string;
|
||||
phone?: string;
|
||||
address?: {
|
||||
street?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
};
|
||||
identityDocument?: {
|
||||
type: 'passport' | 'national_id' | 'drivers_license';
|
||||
number: string;
|
||||
issuingCountry: string;
|
||||
expiryDate?: string;
|
||||
documentHash?: string;
|
||||
};
|
||||
selfieLiveness?: {
|
||||
imageHash: string;
|
||||
livenessScore: number;
|
||||
verifiedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdjudicateRequest {
|
||||
decision: 'approve' | 'reject';
|
||||
reason?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class EResidencyClient {
|
||||
constructor(private client: ApiClient) {}
|
||||
|
||||
async submitApplication(request: SubmitApplicationRequest) {
|
||||
return this.client.post<eResidencyApplication>('/applications', request);
|
||||
}
|
||||
|
||||
async getApplication(id: string) {
|
||||
return this.client.get<eResidencyApplication>(`/applications/${id}`);
|
||||
}
|
||||
|
||||
async getReviewQueue(filters?: {
|
||||
riskBand?: 'low' | 'medium' | 'high';
|
||||
status?: ApplicationStatus;
|
||||
assignedTo?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.riskBand) params.append('riskBand', filters.riskBand);
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.assignedTo) params.append('assignedTo', filters.assignedTo);
|
||||
if (filters?.limit) params.append('limit', filters.limit.toString());
|
||||
if (filters?.offset) params.append('offset', filters.offset.toString());
|
||||
return this.client.get<{
|
||||
applications: eResidencyApplication[];
|
||||
total: number;
|
||||
}>(`/review/queue?${params.toString()}`);
|
||||
}
|
||||
|
||||
async getApplicationForReview(id: string) {
|
||||
return this.client.get<eResidencyApplication>(`/review/applications/${id}`);
|
||||
}
|
||||
|
||||
async adjudicateApplication(id: string, request: AdjudicateRequest) {
|
||||
return this.client.post<eResidencyApplication>(`/review/applications/${id}/adjudicate`, request);
|
||||
}
|
||||
|
||||
async revokeCredential(residentNumber: string, reason: string) {
|
||||
return this.client.post('/applications/revoke', { residentNumber, reason });
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
return this.client.get<Array<{
|
||||
residentNumber: string;
|
||||
status: string;
|
||||
issuedAt?: string;
|
||||
revokedAt?: string;
|
||||
}>>('/status');
|
||||
}
|
||||
}
|
||||
|
||||
111
packages/api-client/src/finance.ts
Normal file
111
packages/api-client/src/finance.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'pending' | 'completed' | 'failed' | 'refunded';
|
||||
paymentMethod: string;
|
||||
createdAt: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface LedgerEntry {
|
||||
id: string;
|
||||
accountId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
type: 'debit' | 'credit';
|
||||
description: string;
|
||||
timestamp: string;
|
||||
reference?: string;
|
||||
}
|
||||
|
||||
export class FinanceClient {
|
||||
protected client: AxiosInstance;
|
||||
|
||||
constructor(baseURL?: string) {
|
||||
const apiBaseURL =
|
||||
baseURL ||
|
||||
(typeof window !== 'undefined'
|
||||
? process.env.NEXT_PUBLIC_FINANCE_SERVICE_URL || 'http://localhost:4005'
|
||||
: 'http://localhost:4005');
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: apiBaseURL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Set up request interceptor for authentication
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = this.getAuthToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
}
|
||||
|
||||
private getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
|
||||
setAuthToken(token: string): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
}
|
||||
|
||||
clearAuthToken(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
}
|
||||
|
||||
async createPayment(data: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
paymentMethod: string;
|
||||
description?: string;
|
||||
}): Promise<Payment> {
|
||||
const response = await this.client.post<Payment>('/api/v1/payments', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getPayment(paymentId: string): Promise<Payment> {
|
||||
const response = await this.client.get<Payment>(`/api/v1/payments/${paymentId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async listPayments(filters?: {
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ payments: Payment[]; total: number }> {
|
||||
const response = await this.client.get<{ payments: Payment[]; total: number }>('/api/v1/payments', {
|
||||
params: filters,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getLedgerEntries(filters?: {
|
||||
accountId?: string;
|
||||
type?: 'debit' | 'credit';
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ entries: LedgerEntry[]; total: number }> {
|
||||
const response = await this.client.get<{ entries: LedgerEntry[]; total: number }>(
|
||||
'/api/v1/ledger',
|
||||
{ params: filters }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
114
packages/api-client/src/identity.ts
Normal file
114
packages/api-client/src/identity.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ApiClient } from './client';
|
||||
import type { eResidentCredential, eCitizenCredential } from '@the-order/schemas';
|
||||
|
||||
export interface IssueVCRequest {
|
||||
subject: string;
|
||||
credentialSubject: Record<string, unknown>;
|
||||
expirationDate?: string;
|
||||
}
|
||||
|
||||
export interface VerifyVCRequest {
|
||||
credential: {
|
||||
id: string;
|
||||
proof?: {
|
||||
jws: string;
|
||||
verificationMethod: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface BatchIssuanceRequest {
|
||||
credentials: Array<{
|
||||
subject: string;
|
||||
credentialSubject: Record<string, unknown>;
|
||||
expirationDate?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CredentialMetrics {
|
||||
issuedToday: number;
|
||||
issuedThisWeek: number;
|
||||
issuedThisMonth: number;
|
||||
issuedThisYear: number;
|
||||
successRate: number;
|
||||
failureRate: number;
|
||||
totalIssuances: number;
|
||||
totalFailures: number;
|
||||
averageIssuanceTime: number;
|
||||
p50IssuanceTime: number;
|
||||
p95IssuanceTime: number;
|
||||
p99IssuanceTime: number;
|
||||
byCredentialType: Record<string, number>;
|
||||
byAction: Record<string, number>;
|
||||
recentIssuances: Array<{
|
||||
credentialId: string;
|
||||
credentialType: string[];
|
||||
issuedAt: Date;
|
||||
subjectDid: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class IdentityClient {
|
||||
constructor(private client: ApiClient) {}
|
||||
|
||||
async issueCredential(request: IssueVCRequest) {
|
||||
return this.client.post<{ credential: eResidentCredential | eCitizenCredential }>('/vc/issue', request);
|
||||
}
|
||||
|
||||
async verifyCredential(request: VerifyVCRequest) {
|
||||
return this.client.post<{ valid: boolean }>('/vc/verify', request);
|
||||
}
|
||||
|
||||
async batchIssue(request: BatchIssuanceRequest) {
|
||||
return this.client.post<{
|
||||
jobId: string;
|
||||
total: number;
|
||||
accepted: number;
|
||||
results: Array<{
|
||||
index: number;
|
||||
credentialId?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}>('/vc/issue/batch', request);
|
||||
}
|
||||
|
||||
async revokeCredential(credentialId: string, reason?: string) {
|
||||
return this.client.post('/vc/revoke', { credentialId, reason });
|
||||
}
|
||||
|
||||
async getMetrics(startDate?: Date, endDate?: Date) {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('startDate', startDate.toISOString());
|
||||
if (endDate) params.append('endDate', endDate.toISOString());
|
||||
return this.client.get<CredentialMetrics>(`/metrics?${params.toString()}`);
|
||||
}
|
||||
|
||||
async getMetricsDashboard() {
|
||||
return this.client.get<{
|
||||
summary: CredentialMetrics;
|
||||
trends: {
|
||||
daily: Array<{ date: string; count: number }>;
|
||||
weekly: Array<{ week: string; count: number }>;
|
||||
monthly: Array<{ month: string; count: number }>;
|
||||
};
|
||||
topCredentialTypes: Array<{ type: string; count: number; percentage: number }>;
|
||||
}>('/metrics/dashboard');
|
||||
}
|
||||
|
||||
async searchAuditLogs(filters: {
|
||||
credentialId?: string;
|
||||
issuerDid?: string;
|
||||
subjectDid?: string;
|
||||
credentialType?: string | string[];
|
||||
action?: 'issued' | 'revoked' | 'verified' | 'renewed';
|
||||
performedBy?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
ipAddress?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
return this.client.post('/metrics/audit/search', filters);
|
||||
}
|
||||
}
|
||||
|
||||
18
packages/api-client/src/index.ts
Normal file
18
packages/api-client/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export { ApiClient, getApiClient } from './client';
|
||||
export { IdentityClient } from './identity';
|
||||
export { EResidencyClient } from './eresidency';
|
||||
export { IntakeClient } from './intake';
|
||||
export { FinanceClient } from './finance';
|
||||
export { DataroomClient } from './dataroom';
|
||||
|
||||
// Export types
|
||||
export type {
|
||||
IssueVCRequest,
|
||||
VerifyVCRequest,
|
||||
BatchIssuanceRequest,
|
||||
CredentialMetrics,
|
||||
} from './identity';
|
||||
export type { SubmitApplicationRequest, AdjudicateRequest } from './eresidency';
|
||||
export type { DocumentUpload, DocumentMetadata } from './intake';
|
||||
export type { Payment, LedgerEntry } from './finance';
|
||||
export type { DealRoom, Document } from './dataroom';
|
||||
104
packages/api-client/src/intake.ts
Normal file
104
packages/api-client/src/intake.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
export interface DocumentUpload {
|
||||
file: File | Blob;
|
||||
documentType: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DocumentMetadata {
|
||||
id: string;
|
||||
documentType: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
uploadedAt: string;
|
||||
status: 'processing' | 'completed' | 'failed';
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class IntakeClient {
|
||||
protected client: AxiosInstance;
|
||||
|
||||
constructor(baseURL?: string) {
|
||||
const apiBaseURL =
|
||||
baseURL ||
|
||||
(typeof window !== 'undefined'
|
||||
? process.env.NEXT_PUBLIC_INTAKE_SERVICE_URL || 'http://localhost:4004'
|
||||
: 'http://localhost:4004');
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: apiBaseURL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Set up request interceptor for authentication
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = this.getAuthToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
}
|
||||
|
||||
private getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
|
||||
setAuthToken(token: string): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
}
|
||||
|
||||
clearAuthToken(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
}
|
||||
|
||||
async uploadDocument(upload: DocumentUpload): Promise<DocumentMetadata> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', upload.file);
|
||||
formData.append('documentType', upload.documentType);
|
||||
if (upload.metadata) {
|
||||
formData.append('metadata', JSON.stringify(upload.metadata));
|
||||
}
|
||||
|
||||
const response = await this.client.post<DocumentMetadata>('/api/v1/documents/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getDocument(documentId: string): Promise<DocumentMetadata> {
|
||||
const response = await this.client.get<DocumentMetadata>(`/api/v1/documents/${documentId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async listDocuments(filters?: {
|
||||
documentType?: string;
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ documents: DocumentMetadata[]; total: number }> {
|
||||
const response = await this.client.get<{ documents: DocumentMetadata[]; total: number }>(
|
||||
'/api/v1/documents',
|
||||
{ params: filters }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteDocument(documentId: string): Promise<void> {
|
||||
await this.client.delete(`/api/v1/documents/${documentId}`);
|
||||
}
|
||||
}
|
||||
13
packages/api-client/tsconfig.json
Normal file
13
packages/api-client/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
*/
|
||||
|
||||
import { EIDASProvider, EIDASSignature } from './eidas';
|
||||
import { EntraVerifiedIDClient, VerifiableCredentialRequest } from './entra-verifiedid';
|
||||
import { EntraVerifiedIDClient, VerifiableCredentialRequest, ClaimValue } from './entra-verifiedid';
|
||||
import { AzureLogicAppsClient } from './azure-logic-apps';
|
||||
import { validateBase64File, FILE_SIZE_LIMITS, encodeFileToBase64, FileValidationOptions } from './file-utils';
|
||||
|
||||
export interface EIDASToEntraConfig {
|
||||
entraVerifiedID: {
|
||||
@@ -67,10 +68,11 @@ export class EIDASToEntraBridge {
|
||||
* Verify eIDAS signature and issue credential via Entra VerifiedID
|
||||
*/
|
||||
async verifyAndIssue(
|
||||
document: string,
|
||||
document: string | Buffer,
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
pin?: string
|
||||
pin?: string,
|
||||
validationOptions?: FileValidationOptions
|
||||
): Promise<{
|
||||
verified: boolean;
|
||||
credentialRequest?: {
|
||||
@@ -78,20 +80,61 @@ export class EIDASToEntraBridge {
|
||||
url: string;
|
||||
qrCode?: string;
|
||||
};
|
||||
errors?: string[];
|
||||
}> {
|
||||
// Step 0: Validate and encode document if needed
|
||||
let documentBase64: string;
|
||||
const errors: string[] = [];
|
||||
|
||||
if (document instanceof Buffer) {
|
||||
// Encode buffer to base64
|
||||
documentBase64 = encodeFileToBase64(document);
|
||||
} else {
|
||||
// Validate base64 string
|
||||
const validation = validateBase64File(
|
||||
document,
|
||||
validationOptions || {
|
||||
maxSize: FILE_SIZE_LIMITS.MEDIUM,
|
||||
allowedMimeTypes: [
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'application/json',
|
||||
'text/plain',
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
verified: false,
|
||||
errors: validation.errors,
|
||||
};
|
||||
}
|
||||
|
||||
documentBase64 = document;
|
||||
}
|
||||
|
||||
// Step 1: Request eIDAS signature
|
||||
let eidasSignature: EIDASSignature;
|
||||
try {
|
||||
eidasSignature = await this.eidasProvider.requestSignature(document);
|
||||
eidasSignature = await this.eidasProvider.requestSignature(documentBase64);
|
||||
} catch (error) {
|
||||
console.error('eIDAS signature request failed:', error);
|
||||
return { verified: false };
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('eIDAS signature request failed:', errorMessage);
|
||||
return {
|
||||
verified: false,
|
||||
errors: [`eIDAS signature request failed: ${errorMessage}`],
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Verify eIDAS signature
|
||||
const verified = await this.eidasProvider.verifySignature(eidasSignature);
|
||||
if (!verified) {
|
||||
return { verified: false };
|
||||
return {
|
||||
verified: false,
|
||||
errors: ['eIDAS signature verification failed'],
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Trigger Logic App workflow if configured
|
||||
@@ -112,7 +155,7 @@ export class EIDASToEntraBridge {
|
||||
claims: {
|
||||
email: userEmail,
|
||||
userId,
|
||||
eidasVerified: 'true',
|
||||
eidasVerified: true, // Boolean value (will be converted to string)
|
||||
eidasCertificate: eidasSignature.certificate,
|
||||
eidasSignatureTimestamp: eidasSignature.timestamp.toISOString(),
|
||||
},
|
||||
@@ -172,7 +215,7 @@ export class EIDASToEntraBridge {
|
||||
eidasVerificationResult: EIDASVerificationResult,
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
additionalClaims?: Record<string, string>,
|
||||
additionalClaims?: Record<string, ClaimValue>,
|
||||
pin?: string
|
||||
): Promise<{
|
||||
requestId: string;
|
||||
@@ -183,10 +226,10 @@ export class EIDASToEntraBridge {
|
||||
throw new Error('eIDAS verification must be successful before issuing credential');
|
||||
}
|
||||
|
||||
const claims: Record<string, string> = {
|
||||
const claims: Record<string, ClaimValue> = {
|
||||
email: userEmail,
|
||||
userId,
|
||||
eidasVerified: 'true',
|
||||
eidasVerified: true, // Boolean value (will be converted to string)
|
||||
eidasCertificate: eidasVerificationResult.eidasSignature.certificate,
|
||||
eidasSignatureTimestamp: eidasVerificationResult.eidasSignature.timestamp.toISOString(),
|
||||
...additionalClaims,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import { validateBase64File, FileValidationOptions, FILE_SIZE_LIMITS } from './file-utils';
|
||||
|
||||
export interface EntraVerifiedIDConfig {
|
||||
tenantId: string;
|
||||
@@ -13,8 +14,16 @@ export interface EntraVerifiedIDConfig {
|
||||
apiVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported claim value types
|
||||
*/
|
||||
export type ClaimValue = string | number | boolean | null;
|
||||
|
||||
/**
|
||||
* Verifiable credential request with enhanced claim types
|
||||
*/
|
||||
export interface VerifiableCredentialRequest {
|
||||
claims: Record<string, string>;
|
||||
claims: Record<string, ClaimValue>;
|
||||
pin?: string;
|
||||
callbackUrl?: string;
|
||||
}
|
||||
@@ -107,12 +116,53 @@ export class EntraVerifiedIDClient {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate credential request
|
||||
*/
|
||||
private validateCredentialRequest(request: VerifiableCredentialRequest): void {
|
||||
if (!request.claims || Object.keys(request.claims).length === 0) {
|
||||
throw new Error('At least one claim is required');
|
||||
}
|
||||
|
||||
// Validate claim keys
|
||||
for (const key of Object.keys(request.claims)) {
|
||||
if (!key || key.trim().length === 0) {
|
||||
throw new Error('Claim keys cannot be empty');
|
||||
}
|
||||
if (key.length > 100) {
|
||||
throw new Error(`Claim key "${key}" exceeds maximum length of 100 characters`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate PIN if provided
|
||||
if (request.pin) {
|
||||
if (request.pin.length < 4 || request.pin.length > 8) {
|
||||
throw new Error('PIN must be between 4 and 8 characters');
|
||||
}
|
||||
if (!/^\d+$/.test(request.pin)) {
|
||||
throw new Error('PIN must contain only digits');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate callback URL if provided
|
||||
if (request.callbackUrl) {
|
||||
try {
|
||||
new URL(request.callbackUrl);
|
||||
} catch {
|
||||
throw new Error('Invalid callback URL format');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a verifiable credential
|
||||
*/
|
||||
async issueCredential(
|
||||
request: VerifiableCredentialRequest
|
||||
): Promise<VerifiableCredentialResponse> {
|
||||
// Validate request
|
||||
this.validateCredentialRequest(request);
|
||||
|
||||
const token = await this.getAccessToken();
|
||||
const manifestId = this.config.credentialManifestId;
|
||||
|
||||
@@ -122,6 +172,20 @@ export class EntraVerifiedIDClient {
|
||||
|
||||
const issueUrl = `${this.baseUrl}/verifiableCredentials/createIssuanceRequest`;
|
||||
|
||||
// Convert claims to string format (Entra VerifiedID requires string values)
|
||||
const stringClaims: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(request.claims)) {
|
||||
if (value === null) {
|
||||
stringClaims[key] = '';
|
||||
} else if (typeof value === 'boolean') {
|
||||
stringClaims[key] = value.toString();
|
||||
} else if (typeof value === 'number') {
|
||||
stringClaims[key] = value.toString();
|
||||
} else {
|
||||
stringClaims[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
includeQRCode: true,
|
||||
callback: request.callbackUrl
|
||||
@@ -142,7 +206,7 @@ export class EntraVerifiedIDClient {
|
||||
length: request.pin.length,
|
||||
}
|
||||
: undefined,
|
||||
claims: request.claims,
|
||||
claims: stringClaims,
|
||||
};
|
||||
|
||||
const response = await fetch(issueUrl, {
|
||||
@@ -196,30 +260,75 @@ export class EntraVerifiedIDClient {
|
||||
return (await response.json()) as VerifiableCredentialStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate credential structure
|
||||
*/
|
||||
private validateCredential(credential: VerifiedCredential): void {
|
||||
if (!credential.id) {
|
||||
throw new Error('Credential ID is required');
|
||||
}
|
||||
|
||||
if (!credential.type || !Array.isArray(credential.type) || credential.type.length === 0) {
|
||||
throw new Error('Credential type is required and must be an array');
|
||||
}
|
||||
|
||||
if (!credential.issuer) {
|
||||
throw new Error('Credential issuer is required');
|
||||
}
|
||||
|
||||
if (!credential.issuanceDate) {
|
||||
throw new Error('Credential issuance date is required');
|
||||
}
|
||||
|
||||
if (!credential.credentialSubject || typeof credential.credentialSubject !== 'object') {
|
||||
throw new Error('Credential subject is required');
|
||||
}
|
||||
|
||||
if (!credential.proof) {
|
||||
throw new Error('Credential proof is required');
|
||||
}
|
||||
|
||||
// Validate proof structure
|
||||
if (!credential.proof.type || !credential.proof.jws) {
|
||||
throw new Error('Credential proof must include type and jws');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a verifiable credential
|
||||
*/
|
||||
async verifyCredential(credential: VerifiedCredential): Promise<boolean> {
|
||||
// Validate credential structure
|
||||
this.validateCredential(credential);
|
||||
|
||||
const token = await this.getAccessToken();
|
||||
const verifyUrl = `${this.baseUrl}/verifiableCredentials/verify`;
|
||||
|
||||
const response = await fetch(verifyUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
verifiableCredential: credential,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const response = await fetch(verifyUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
verifiableCredential: credential,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Verification failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { verified: boolean };
|
||||
return result.verified ?? false;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Verification failed')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to verify credential: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { verified: boolean };
|
||||
return result.verified ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
173
packages/auth/src/file-utils.test.ts
Normal file
173
packages/auth/src/file-utils.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Tests for file utilities
|
||||
* Run with: pnpm test file-utils
|
||||
*/
|
||||
|
||||
import {
|
||||
encodeFileToBase64,
|
||||
decodeBase64ToBuffer,
|
||||
isBase64,
|
||||
detectMimeType,
|
||||
validateBase64File,
|
||||
encodeFileWithMetadata,
|
||||
sanitizeFilename,
|
||||
calculateFileHash,
|
||||
FILE_SIZE_LIMITS,
|
||||
SUPPORTED_MIME_TYPES,
|
||||
} from './file-utils';
|
||||
|
||||
describe('File Utilities', () => {
|
||||
describe('encodeFileToBase64', () => {
|
||||
it('should encode buffer to base64', () => {
|
||||
const buffer = Buffer.from('test content');
|
||||
const result = encodeFileToBase64(buffer);
|
||||
expect(result).toBe('dGVzdCBjb250ZW50');
|
||||
});
|
||||
|
||||
it('should encode string to base64', () => {
|
||||
const result = encodeFileToBase64('test content');
|
||||
expect(result).toBe('dGVzdCBjb250ZW50');
|
||||
});
|
||||
|
||||
it('should return base64 string as-is if already encoded', () => {
|
||||
const base64 = 'dGVzdCBjb250ZW50';
|
||||
const result = encodeFileToBase64(base64);
|
||||
expect(result).toBe(base64);
|
||||
});
|
||||
|
||||
it('should include MIME type in data URL format', () => {
|
||||
const buffer = Buffer.from('test');
|
||||
const result = encodeFileToBase64(buffer, 'application/json');
|
||||
expect(result).toContain('data:application/json;base64,');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeBase64ToBuffer', () => {
|
||||
it('should decode base64 to buffer', () => {
|
||||
const base64 = 'dGVzdCBjb250ZW50';
|
||||
const buffer = decodeBase64ToBuffer(base64);
|
||||
expect(buffer.toString()).toBe('test content');
|
||||
});
|
||||
|
||||
it('should handle data URL format', () => {
|
||||
const dataUrl = 'data:application/json;base64,dGVzdCBjb250ZW50';
|
||||
const buffer = decodeBase64ToBuffer(dataUrl);
|
||||
expect(buffer.toString()).toBe('test content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBase64', () => {
|
||||
it('should return true for valid base64', () => {
|
||||
expect(isBase64('dGVzdCBjb250ZW50')).toBe(true);
|
||||
expect(isBase64('SGVsbG8gV29ybGQ=')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid base64', () => {
|
||||
expect(isBase64('not base64!@#')).toBe(false);
|
||||
expect(isBase64('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle data URL format', () => {
|
||||
expect(isBase64('data:application/json;base64,dGVzdA==')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectMimeType', () => {
|
||||
it('should detect PDF from buffer', () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n');
|
||||
const mimeType = detectMimeType(pdfBuffer);
|
||||
expect(mimeType).toBe(SUPPORTED_MIME_TYPES.PDF);
|
||||
});
|
||||
|
||||
it('should detect PNG from buffer', () => {
|
||||
const pngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);
|
||||
const mimeType = detectMimeType(pngBuffer);
|
||||
expect(mimeType).toBe(SUPPORTED_MIME_TYPES.PNG);
|
||||
});
|
||||
|
||||
it('should detect MIME type from filename', () => {
|
||||
const mimeType = detectMimeType(Buffer.from('test'), 'document.pdf');
|
||||
expect(mimeType).toBe(SUPPORTED_MIME_TYPES.PDF);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBase64File', () => {
|
||||
it('should validate valid base64 file', () => {
|
||||
const base64 = encodeFileToBase64(Buffer.from('test content'));
|
||||
const result = validateBase64File(base64);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject invalid base64', () => {
|
||||
const result = validateBase64File('invalid base64!@#');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should enforce size limits', () => {
|
||||
const largeBuffer = Buffer.alloc(FILE_SIZE_LIMITS.MEDIUM + 1);
|
||||
const base64 = encodeFileToBase64(largeBuffer);
|
||||
const result = validateBase64File(base64, {
|
||||
maxSize: FILE_SIZE_LIMITS.MEDIUM,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('exceeds maximum'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate MIME types', () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n');
|
||||
const base64 = encodeFileToBase64(pdfBuffer);
|
||||
const result = validateBase64File(base64, {
|
||||
allowedMimeTypes: [SUPPORTED_MIME_TYPES.PDF],
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject disallowed MIME types', () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n');
|
||||
const base64 = encodeFileToBase64(pdfBuffer);
|
||||
const result = validateBase64File(base64, {
|
||||
allowedMimeTypes: [SUPPORTED_MIME_TYPES.PNG],
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFilename', () => {
|
||||
it('should sanitize unsafe characters', () => {
|
||||
const filename = '../../etc/passwd';
|
||||
const sanitized = sanitizeFilename(filename);
|
||||
expect(sanitized).not.toContain('/');
|
||||
expect(sanitized).not.toContain('..');
|
||||
});
|
||||
|
||||
it('should preserve safe characters', () => {
|
||||
const filename = 'document_123.pdf';
|
||||
const sanitized = sanitizeFilename(filename);
|
||||
expect(sanitized).toBe('document_123.pdf');
|
||||
});
|
||||
|
||||
it('should limit filename length', () => {
|
||||
const longFilename = 'a'.repeat(300) + '.pdf';
|
||||
const sanitized = sanitizeFilename(longFilename);
|
||||
expect(sanitized.length).toBeLessThanOrEqual(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateFileHash', () => {
|
||||
it('should calculate SHA256 hash', () => {
|
||||
const data = Buffer.from('test content');
|
||||
const hash = calculateFileHash(data);
|
||||
expect(hash).toHaveLength(64); // SHA256 produces 64 hex characters
|
||||
expect(typeof hash).toBe('string');
|
||||
});
|
||||
|
||||
it('should calculate SHA512 hash', () => {
|
||||
const data = Buffer.from('test content');
|
||||
const hash = calculateFileHash(data, 'sha512');
|
||||
expect(hash).toHaveLength(128); // SHA512 produces 128 hex characters
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
377
packages/auth/src/file-utils.ts
Normal file
377
packages/auth/src/file-utils.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* File handling utilities for Entra VerifiedID and other integrations
|
||||
* Provides base64 encoding/decoding, validation, and content type detection
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
/**
|
||||
* Supported MIME types for document processing
|
||||
*/
|
||||
export const SUPPORTED_MIME_TYPES = {
|
||||
// Documents
|
||||
PDF: 'application/pdf',
|
||||
DOCX: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
DOC: 'application/msword',
|
||||
XLSX: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
XLS: 'application/vnd.ms-excel',
|
||||
// Images
|
||||
PNG: 'image/png',
|
||||
JPEG: 'image/jpeg',
|
||||
JPG: 'image/jpg',
|
||||
GIF: 'image/gif',
|
||||
WEBP: 'image/webp',
|
||||
// Text
|
||||
TEXT: 'text/plain',
|
||||
JSON: 'application/json',
|
||||
XML: 'application/xml',
|
||||
// Archives
|
||||
ZIP: 'application/zip',
|
||||
TAR: 'application/x-tar',
|
||||
GZIP: 'application/gzip',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Maximum file size limits (in bytes)
|
||||
*/
|
||||
export const FILE_SIZE_LIMITS = {
|
||||
SMALL: 1024 * 1024, // 1 MB
|
||||
MEDIUM: 10 * 1024 * 1024, // 10 MB
|
||||
LARGE: 100 * 1024 * 1024, // 100 MB
|
||||
XLARGE: 500 * 1024 * 1024, // 500 MB
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* File validation options
|
||||
*/
|
||||
export interface FileValidationOptions {
|
||||
maxSize?: number;
|
||||
allowedMimeTypes?: string[];
|
||||
requireMimeType?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* File encoding result
|
||||
*/
|
||||
export interface FileEncodingResult {
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* File validation result
|
||||
*/
|
||||
export interface FileValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
mimeType?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a file buffer or string to base64
|
||||
*/
|
||||
export function encodeFileToBase64(
|
||||
file: Buffer | string | Uint8Array,
|
||||
mimeType?: string
|
||||
): string {
|
||||
let buffer: Buffer;
|
||||
|
||||
if (typeof file === 'string') {
|
||||
// If it's already base64, validate and return
|
||||
if (isBase64(file)) {
|
||||
return file;
|
||||
}
|
||||
// Otherwise, convert string to buffer
|
||||
buffer = Buffer.from(file, 'utf-8');
|
||||
} else if (file instanceof Uint8Array) {
|
||||
buffer = Buffer.from(file);
|
||||
} else {
|
||||
buffer = file;
|
||||
}
|
||||
|
||||
const base64 = buffer.toString('base64');
|
||||
|
||||
// If MIME type is provided, return as data URL
|
||||
if (mimeType) {
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
}
|
||||
|
||||
return base64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 string to buffer
|
||||
*/
|
||||
export function decodeBase64ToBuffer(base64: string): Buffer {
|
||||
// Remove data URL prefix if present
|
||||
const base64Data = base64.includes(',')
|
||||
? base64.split(',')[1]
|
||||
: base64;
|
||||
|
||||
return Buffer.from(base64Data, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is valid base64
|
||||
*/
|
||||
export function isBase64(str: string): boolean {
|
||||
if (!str || str.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove data URL prefix if present
|
||||
const base64Data = str.includes(',')
|
||||
? str.split(',')[1]
|
||||
: str;
|
||||
|
||||
// Base64 regex pattern
|
||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
|
||||
if (!base64Regex.test(base64Data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check length is multiple of 4
|
||||
if (base64Data.length % 4 !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to decode
|
||||
try {
|
||||
Buffer.from(base64Data, 'base64');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIME type from buffer or file extension
|
||||
*/
|
||||
export function detectMimeType(
|
||||
data: Buffer | string,
|
||||
filename?: string
|
||||
): string {
|
||||
// Try to detect from file extension first
|
||||
if (filename) {
|
||||
const extension = filename.split('.').pop()?.toLowerCase();
|
||||
const mimeType = getMimeTypeFromExtension(extension || '');
|
||||
if (mimeType) {
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to detect from buffer magic bytes
|
||||
if (data instanceof Buffer && data.length > 0) {
|
||||
const mimeType = detectMimeTypeFromBuffer(data);
|
||||
if (mimeType) {
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to application/octet-stream
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type from file extension
|
||||
*/
|
||||
function getMimeTypeFromExtension(extension: string): string | null {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
// Documents
|
||||
pdf: SUPPORTED_MIME_TYPES.PDF,
|
||||
docx: SUPPORTED_MIME_TYPES.DOCX,
|
||||
doc: SUPPORTED_MIME_TYPES.DOC,
|
||||
xlsx: SUPPORTED_MIME_TYPES.XLSX,
|
||||
xls: SUPPORTED_MIME_TYPES.XLS,
|
||||
// Images
|
||||
png: SUPPORTED_MIME_TYPES.PNG,
|
||||
jpg: SUPPORTED_MIME_TYPES.JPG,
|
||||
jpeg: SUPPORTED_MIME_TYPES.JPEG,
|
||||
gif: SUPPORTED_MIME_TYPES.GIF,
|
||||
webp: SUPPORTED_MIME_TYPES.WEBP,
|
||||
// Text
|
||||
txt: SUPPORTED_MIME_TYPES.TEXT,
|
||||
json: SUPPORTED_MIME_TYPES.JSON,
|
||||
xml: SUPPORTED_MIME_TYPES.XML,
|
||||
// Archives
|
||||
zip: SUPPORTED_MIME_TYPES.ZIP,
|
||||
tar: SUPPORTED_MIME_TYPES.TAR,
|
||||
gz: SUPPORTED_MIME_TYPES.GZIP,
|
||||
};
|
||||
|
||||
return mimeTypes[extension.toLowerCase()] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIME type from buffer magic bytes
|
||||
*/
|
||||
function detectMimeTypeFromBuffer(buffer: Buffer): string | null {
|
||||
// Check magic bytes (file signatures)
|
||||
if (buffer.length < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const header = buffer.slice(0, 12);
|
||||
|
||||
// PDF
|
||||
if (header.slice(0, 4).toString() === '%PDF') {
|
||||
return SUPPORTED_MIME_TYPES.PDF;
|
||||
}
|
||||
|
||||
// PNG
|
||||
if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4E && header[3] === 0x47) {
|
||||
return SUPPORTED_MIME_TYPES.PNG;
|
||||
}
|
||||
|
||||
// JPEG
|
||||
if (header[0] === 0xFF && header[1] === 0xD8 && header[2] === 0xFF) {
|
||||
return SUPPORTED_MIME_TYPES.JPEG;
|
||||
}
|
||||
|
||||
// GIF
|
||||
if (header.slice(0, 3).toString() === 'GIF') {
|
||||
return SUPPORTED_MIME_TYPES.GIF;
|
||||
}
|
||||
|
||||
// ZIP (also detects DOCX, XLSX which are ZIP-based)
|
||||
if (header[0] === 0x50 && header[1] === 0x4B) {
|
||||
// Check if it's a DOCX or XLSX by checking internal structure
|
||||
// For simplicity, return ZIP - caller can refine based on filename
|
||||
return SUPPORTED_MIME_TYPES.ZIP;
|
||||
}
|
||||
|
||||
// JSON (starts with { or [)
|
||||
const text = buffer.slice(0, 100).toString('utf-8').trim();
|
||||
if (text.startsWith('{') || text.startsWith('[')) {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
return SUPPORTED_MIME_TYPES.JSON;
|
||||
} catch {
|
||||
// Not valid JSON
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a base64-encoded file
|
||||
*/
|
||||
export function validateBase64File(
|
||||
base64: string,
|
||||
options: FileValidationOptions = {}
|
||||
): FileValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check if it's valid base64
|
||||
if (!isBase64(base64)) {
|
||||
errors.push('Invalid base64 encoding');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// Decode to get size
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = decodeBase64ToBuffer(base64);
|
||||
} catch (error) {
|
||||
errors.push(`Failed to decode base64: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
const size = buffer.length;
|
||||
|
||||
// Check size limit
|
||||
if (options.maxSize && size > options.maxSize) {
|
||||
errors.push(`File size (${size} bytes) exceeds maximum allowed size (${options.maxSize} bytes)`);
|
||||
}
|
||||
|
||||
// Detect and validate MIME type
|
||||
let mimeType: string | undefined;
|
||||
try {
|
||||
mimeType = detectMimeTypeFromBuffer(buffer);
|
||||
} catch {
|
||||
// MIME type detection failed, but not critical
|
||||
}
|
||||
|
||||
if (options.requireMimeType && !mimeType) {
|
||||
errors.push('Could not detect file MIME type');
|
||||
}
|
||||
|
||||
if (options.allowedMimeTypes && mimeType) {
|
||||
if (!options.allowedMimeTypes.includes(mimeType)) {
|
||||
errors.push(`MIME type ${mimeType} is not allowed. Allowed types: ${options.allowedMimeTypes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
mimeType,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode file with full metadata
|
||||
*/
|
||||
export function encodeFileWithMetadata(
|
||||
file: Buffer | string | Uint8Array,
|
||||
filename?: string,
|
||||
mimeType?: string
|
||||
): FileEncodingResult {
|
||||
let buffer: Buffer;
|
||||
|
||||
if (typeof file === 'string') {
|
||||
if (isBase64(file)) {
|
||||
buffer = decodeBase64ToBuffer(file);
|
||||
} else {
|
||||
buffer = Buffer.from(file, 'utf-8');
|
||||
}
|
||||
} else if (file instanceof Uint8Array) {
|
||||
buffer = Buffer.from(file);
|
||||
} else {
|
||||
buffer = file;
|
||||
}
|
||||
|
||||
const detectedMimeType = mimeType || detectMimeType(buffer, filename);
|
||||
const base64 = encodeFileToBase64(buffer, detectedMimeType);
|
||||
const hash = createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
return {
|
||||
base64,
|
||||
mimeType: detectedMimeType,
|
||||
size: buffer.length,
|
||||
hash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename for safe storage
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
// Remove path components
|
||||
const basename = filename.split(/[/\\]/).pop() || filename;
|
||||
|
||||
// Remove or replace unsafe characters
|
||||
return basename
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.substring(0, 255); // Limit length
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate file hash for integrity verification
|
||||
*/
|
||||
export function calculateFileHash(data: Buffer | string, algorithm: 'sha256' | 'sha512' = 'sha256'): string {
|
||||
const buffer = typeof data === 'string'
|
||||
? Buffer.from(data, 'utf-8')
|
||||
: data;
|
||||
|
||||
return createHash(algorithm).update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ export * from './eidas';
|
||||
export * from './entra-verifiedid';
|
||||
export * from './azure-logic-apps';
|
||||
export * from './eidas-entra-bridge';
|
||||
export * from './file-utils';
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"class-variance-authority": "^0.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.45",
|
||||
|
||||
46
packages/ui/src/components/Alert.tsx
Normal file
46
packages/ui/src/components/Alert.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'destructive' | 'success' | 'warning';
|
||||
}
|
||||
|
||||
export const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => {
|
||||
const variantClasses = {
|
||||
default: 'bg-background text-foreground border-border',
|
||||
destructive: 'bg-destructive/10 text-destructive border-destructive/20',
|
||||
success: 'bg-green-50 text-green-800 border-green-200',
|
||||
warning: 'bg-yellow-50 text-yellow-800 border-yellow-200',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(
|
||||
'relative w-full rounded-lg border p-4',
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Alert.displayName = 'Alert';
|
||||
|
||||
export const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return <h5 ref={ref} className={cn('mb-1 font-medium leading-none tracking-tight', className)} {...props} />;
|
||||
}
|
||||
);
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
|
||||
export const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return <div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />;
|
||||
}
|
||||
);
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
|
||||
31
packages/ui/src/components/Badge.tsx
Normal file
31
packages/ui/src/components/Badge.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-green-500 text-white hover:bg-green-600',
|
||||
warning: 'border-transparent bg-yellow-500 text-white hover:bg-yellow-600',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
42
packages/ui/src/components/Breadcrumbs.tsx
Normal file
42
packages/ui/src/components/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Breadcrumbs({ items, className }: BreadcrumbsProps) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={cn('flex', className)}>
|
||||
<ol className="flex items-center space-x-2 text-sm">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
return (
|
||||
<li key={index} className="flex items-center">
|
||||
{index > 0 && <span className="text-gray-400 mx-2">/</span>}
|
||||
{isLast ? (
|
||||
<span className="text-gray-900 font-medium" aria-current="page">
|
||||
{item.label}
|
||||
</span>
|
||||
) : item.href ? (
|
||||
<Link href={item.href} className="text-gray-600 hover:text-gray-900">
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-gray-600">{item.label}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-9 px-3',
|
||||
md: 'h-10 px-4 py-2',
|
||||
lg: 'h-11 px-8',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const baseClasses = 'font-medium rounded-lg transition-colors';
|
||||
const variantClasses = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
secondary: 'bg-gray-600 text-white hover:bg-gray-700',
|
||||
outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50',
|
||||
};
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-base',
|
||||
lg: 'px-6 py-3 text-lg',
|
||||
};
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
|
||||
59
packages/ui/src/components/Card.tsx
Normal file
59
packages/ui/src/components/Card.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'outline';
|
||||
}
|
||||
|
||||
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
variant === 'outline' && 'border-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />;
|
||||
}
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
export const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return <h3 ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />;
|
||||
}
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
export const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />;
|
||||
}
|
||||
);
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />;
|
||||
}
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />;
|
||||
}
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
31
packages/ui/src/components/Checkbox.tsx
Normal file
31
packages/ui/src/components/Checkbox.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, label, ...props }, ref) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className={cn(
|
||||
'h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{label && (
|
||||
<label htmlFor={props.id} className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
|
||||
83
packages/ui/src/components/Dropdown.tsx
Normal file
83
packages/ui/src/components/Dropdown.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface DropdownItem {
|
||||
label: string;
|
||||
value: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
export interface DropdownProps {
|
||||
trigger: React.ReactNode;
|
||||
items: DropdownItem[];
|
||||
align?: 'left' | 'right';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Dropdown({ trigger, items, align = 'left', className }: DropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleItemClick = (item: DropdownItem) => {
|
||||
if (item.disabled) return;
|
||||
item.onClick?.();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative inline-block', className)} ref={dropdownRef}>
|
||||
<div onClick={() => setIsOpen(!isOpen)} className="cursor-pointer">
|
||||
{trigger}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-50 mt-2 min-w-[200px] rounded-md border bg-white shadow-lg',
|
||||
align === 'right' ? 'right-0' : 'left-0'
|
||||
)}
|
||||
>
|
||||
<div className="py-1">
|
||||
{items.map((item, index) => {
|
||||
if (item.divider) {
|
||||
return <div key={index} className="my-1 border-t" />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
className={cn(
|
||||
'w-full text-left px-4 py-2 text-sm hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
item.disabled && 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
22
packages/ui/src/components/Input.tsx
Normal file
22
packages/ui/src/components/Input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
21
packages/ui/src/components/Label.tsx
Normal file
21
packages/ui/src/components/Label.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||
|
||||
export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Label.displayName = 'Label';
|
||||
|
||||
125
packages/ui/src/components/Modal.tsx
Normal file
125
packages/ui/src/components/Modal.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './Button';
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
size = 'md',
|
||||
showCloseButton = true,
|
||||
}: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/50" />
|
||||
<div
|
||||
className={cn(
|
||||
'relative bg-white rounded-lg shadow-xl w-full mx-4',
|
||||
sizeClasses[size],
|
||||
'animate-in fade-in zoom-in-95'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<div>
|
||||
{title && <h2 className="text-xl font-semibold">{title}</h2>}
|
||||
{description && <p className="text-sm text-gray-600 mt-1">{description}</p>}
|
||||
</div>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
variant = 'default',
|
||||
}: ConfirmModalProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={title} size="sm">
|
||||
<p className="text-gray-700 mb-6">{message}</p>
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button variant={variant === 'destructive' ? 'destructive' : 'primary'} onClick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
31
packages/ui/src/components/Radio.tsx
Normal file
31
packages/ui/src/components/Radio.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface RadioProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
|
||||
({ className, label, ...props }, ref) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
className={cn(
|
||||
'h-4 w-4 border-gray-300 text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{label && (
|
||||
<label htmlFor={props.id} className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Radio.displayName = 'Radio';
|
||||
|
||||
23
packages/ui/src/components/Select.tsx
Normal file
23
packages/ui/src/components/Select.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
);
|
||||
Select.displayName = 'Select';
|
||||
|
||||
9
packages/ui/src/components/Skeleton.tsx
Normal file
9
packages/ui/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export function Skeleton({ className, ...props }: SkeletonProps) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
34
packages/ui/src/components/Switch.tsx
Normal file
34
packages/ui/src/components/Switch.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, label, ...props }, ref) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
70
packages/ui/src/components/Table.tsx
Normal file
70
packages/ui/src/components/Table.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
export const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />;
|
||||
});
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
export const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return <tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />;
|
||||
});
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
export const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
export const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
export const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<td ref={ref} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
|
||||
);
|
||||
});
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
118
packages/ui/src/components/Tabs.tsx
Normal file
118
packages/ui/src/components/Tabs.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface TabsContextType {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const TabsContext = createContext<TabsContextType | undefined>(undefined);
|
||||
|
||||
export interface TabsProps {
|
||||
defaultValue?: string;
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tabs({ defaultValue, value: controlledValue, onValueChange, children, className }: TabsProps) {
|
||||
const [internalValue, setInternalValue] = useState(defaultValue || '');
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const currentValue = isControlled ? controlledValue : internalValue;
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
if (!isControlled) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onValueChange?.(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ value: currentValue, onValueChange: handleValueChange }}>
|
||||
<div className={cn('w-full', className)}>{children}</div>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TabsListProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabsList({ children, className }: TabsListProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
role="tablist"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TabsTriggerProps {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabsTrigger({ value, children, className }: TabsTriggerProps) {
|
||||
const context = useContext(TabsContext);
|
||||
if (!context) {
|
||||
throw new Error('TabsTrigger must be used within Tabs');
|
||||
}
|
||||
|
||||
const { value: currentValue, onValueChange } = context;
|
||||
const isActive = currentValue === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => onValueChange(value)}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TabsContentProps {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabsContent({ value, children, className }: TabsContentProps) {
|
||||
const context = useContext(TabsContext);
|
||||
if (!context) {
|
||||
throw new Error('TabsContent must be used within Tabs');
|
||||
}
|
||||
|
||||
const { value: currentValue } = context;
|
||||
if (currentValue !== value) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
role="tabpanel"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
21
packages/ui/src/components/Textarea.tsx
Normal file
21
packages/ui/src/components/Textarea.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
144
packages/ui/src/components/Toast.tsx
Normal file
144
packages/ui/src/components/Toast.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Alert, AlertDescription } from './Alert';
|
||||
|
||||
export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
title?: string;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, 'id'>) => void;
|
||||
removeToast: (id: string) => void;
|
||||
success: (message: string, title?: string) => void;
|
||||
error: (message: string, title?: string) => void;
|
||||
warning: (message: string, title?: string) => void;
|
||||
info: (message: string, title?: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback(
|
||||
(toast: Omit<Toast, 'id'>) => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
const newToast: Toast = {
|
||||
...toast,
|
||||
id,
|
||||
duration: toast.duration || 5000,
|
||||
};
|
||||
|
||||
setToasts((prev) => [...prev, newToast]);
|
||||
|
||||
if (newToast.duration && newToast.duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, newToast.duration);
|
||||
}
|
||||
},
|
||||
[removeToast]
|
||||
);
|
||||
|
||||
const success = useCallback(
|
||||
(message: string, title?: string) => {
|
||||
addToast({ message, title, variant: 'success' });
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const error = useCallback(
|
||||
(message: string, title?: string) => {
|
||||
addToast({ message, title, variant: 'error' });
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const warning = useCallback(
|
||||
(message: string, title?: string) => {
|
||||
addToast({ message, title, variant: 'warning' });
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
const info = useCallback(
|
||||
(message: string, title?: string) => {
|
||||
addToast({ message, title, variant: 'info' });
|
||||
},
|
||||
[addToast]
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast, success, error, warning, info }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastContainer({ toasts, removeToast }: { toasts: Toast[]; removeToast: (id: string) => void }) {
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-full max-w-sm">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onRemove={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: string) => void }) {
|
||||
const variantMap: Record<ToastVariant, { alert: 'default' | 'destructive' | 'success' | 'warning'; icon: string }> = {
|
||||
default: { alert: 'default', icon: 'ℹ️' },
|
||||
success: { alert: 'success', icon: '✓' },
|
||||
error: { alert: 'destructive', icon: '✗' },
|
||||
warning: { alert: 'warning', icon: '⚠' },
|
||||
info: { alert: 'default', icon: 'ℹ️' },
|
||||
};
|
||||
|
||||
const variant = variantMap[toast.variant];
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant={variant.alert}
|
||||
className="animate-in slide-in-from-top-5 fade-in shadow-lg"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
{toast.title && (
|
||||
<AlertDescription className="font-semibold mb-1">{toast.title}</AlertDescription>
|
||||
)}
|
||||
<AlertDescription>{toast.message}</AlertDescription>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRemove(toast.id)}
|
||||
className="ml-4 text-gray-500 hover:text-gray-700 text-xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,4 +4,21 @@
|
||||
|
||||
// Export components here as they are created
|
||||
export { Button } from './Button';
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
|
||||
export { Input } from './Input';
|
||||
export { Label } from './Label';
|
||||
export { Select } from './Select';
|
||||
export { Textarea } from './Textarea';
|
||||
export { Alert, AlertTitle, AlertDescription } from './Alert';
|
||||
export { Badge } from './Badge';
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table';
|
||||
export { Skeleton } from './Skeleton';
|
||||
export { ToastProvider, useToast } from './Toast';
|
||||
export { Modal, ConfirmModal } from './Modal';
|
||||
export { Breadcrumbs } from './Breadcrumbs';
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent } from './Tabs';
|
||||
export { Checkbox } from './Checkbox';
|
||||
export { Radio } from './Radio';
|
||||
export { Switch } from './Switch';
|
||||
export { Dropdown } from './Dropdown';
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
*/
|
||||
|
||||
export * from './components';
|
||||
export * from './lib/utils';
|
||||
|
||||
|
||||
7
packages/ui/src/lib/utils.ts
Normal file
7
packages/ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user