2353 lines
57 KiB
Markdown
2353 lines
57 KiB
Markdown
# MonacoUSA Portal - Complete Implementation Guide
|
|
|
|
## Overview
|
|
|
|
This document provides step-by-step instructions to create a complete portal foundation for MonacoUSA using the same proven tech stack as the Port Nimara client portal. The portal will feature Keycloak authentication, responsive design, PWA capabilities, and a modular structure for adding custom tools.
|
|
|
|
## Project Specifications
|
|
|
|
- **Project Name**: monacousa-portal
|
|
- **Domain**: monacousa.org (configurable)
|
|
- **Primary Color**: #a31515 (MonacoUSA red)
|
|
- **Secondary Color**: #ffffff (white)
|
|
- **Framework**: Nuxt 3 with Vue 3
|
|
- **UI Library**: Vuetify 3
|
|
- **Authentication**: Keycloak (OAuth2/OIDC)
|
|
- **Database**: NocoDB
|
|
- **File Storage**: MinIO (S3-compatible)
|
|
- **Features**: PWA, Mobile-responsive, Dashboard layout
|
|
|
|
## Prerequisites
|
|
|
|
Before starting, ensure you have:
|
|
- Node.js 18+ installed
|
|
- Git installed
|
|
- Access to a Keycloak server
|
|
- NocoDB instance (or ability to set one up)
|
|
- MinIO instance (or ability to set one up)
|
|
|
|
## Phase 1: Project Initialization
|
|
|
|
### 1.1 Create New Repository
|
|
|
|
```bash
|
|
mkdir monacousa-portal
|
|
cd monacousa-portal
|
|
git init
|
|
```
|
|
|
|
### 1.2 Initialize Nuxt 3 Project
|
|
|
|
```bash
|
|
npx nuxi@latest init .
|
|
```
|
|
|
|
### 1.3 Install Core Dependencies
|
|
|
|
```bash
|
|
npm install @nuxt/ui@^3.2.0 vuetify-nuxt-module@^0.18.3 @vite-pwa/nuxt@^0.10.6 motion-v@^1.6.1
|
|
```
|
|
|
|
### 1.4 Install Additional Dependencies
|
|
|
|
```bash
|
|
npm install @types/node formidable@^3.5.4 mime-types@^3.0.1 minio@^8.0.5 sharp@^0.34.2
|
|
npm install -D @types/formidable@^3.4.5 @types/mime-types@^3.0.1
|
|
```
|
|
|
|
## Phase 2: Project Structure Setup
|
|
|
|
### 2.1 Create Directory Structure
|
|
|
|
```bash
|
|
mkdir -p components composables layouts middleware pages/auth pages/dashboard server/api server/utils server/plugins utils docs public/icons
|
|
```
|
|
|
|
### 2.2 Create package.json
|
|
|
|
```json
|
|
{
|
|
"name": "monacousa-portal",
|
|
"type": "module",
|
|
"scripts": {
|
|
"build": "nuxt build",
|
|
"dev": "nuxt dev",
|
|
"generate": "nuxt generate",
|
|
"preview": "nuxt preview",
|
|
"postinstall": "nuxt prepare"
|
|
},
|
|
"dependencies": {
|
|
"@nuxt/ui": "^3.2.0",
|
|
"@vite-pwa/nuxt": "^0.10.6",
|
|
"formidable": "^3.5.4",
|
|
"mime-types": "^3.0.1",
|
|
"minio": "^8.0.5",
|
|
"motion-v": "^1.6.1",
|
|
"nuxt": "^3.15.4",
|
|
"sharp": "^0.34.2",
|
|
"vue": "latest",
|
|
"vue-router": "latest",
|
|
"vuetify-nuxt-module": "^0.18.3"
|
|
},
|
|
"devDependencies": {
|
|
"@types/formidable": "^3.4.5",
|
|
"@types/mime-types": "^3.0.1"
|
|
}
|
|
}
|
|
```
|
|
|
|
## Phase 3: Core Configuration
|
|
|
|
### 3.1 Create nuxt.config.ts
|
|
|
|
```typescript
|
|
export default defineNuxtConfig({
|
|
ssr: false,
|
|
compatibilityDate: "2024-11-01",
|
|
devtools: { enabled: true },
|
|
modules: ["vuetify-nuxt-module", "@vite-pwa/nuxt", "motion-v/nuxt"],
|
|
app: {
|
|
head: {
|
|
titleTemplate: "%s • MonacoUSA Portal",
|
|
title: "MonacoUSA Portal",
|
|
meta: [
|
|
{ property: "og:title", content: "MonacoUSA Portal" },
|
|
{ property: "og:image", content: "/og-image.png" },
|
|
{ name: "twitter:card", content: "summary_large_image" },
|
|
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
|
{ name: "apple-mobile-web-app-capable", content: "yes" },
|
|
{ name: "apple-mobile-web-app-status-bar-style", content: "default" },
|
|
{ name: "apple-mobile-web-app-title", content: "MonacoUSA Portal" },
|
|
],
|
|
htmlAttrs: {
|
|
lang: "en",
|
|
},
|
|
},
|
|
},
|
|
pwa: {
|
|
registerType: 'autoUpdate',
|
|
manifest: {
|
|
name: 'MonacoUSA Portal',
|
|
short_name: 'MonacoUSA',
|
|
description: 'MonacoUSA Portal - Unified dashboard for tools and services',
|
|
theme_color: '#a31515',
|
|
background_color: '#ffffff',
|
|
display: 'standalone',
|
|
orientation: 'portrait',
|
|
start_url: '/',
|
|
scope: '/',
|
|
icons: [
|
|
{
|
|
src: '/icons/icon-72x72.png',
|
|
sizes: '72x72',
|
|
type: 'image/png'
|
|
},
|
|
{
|
|
src: '/icons/icon-96x96.png',
|
|
sizes: '96x96',
|
|
type: 'image/png'
|
|
},
|
|
{
|
|
src: '/icons/icon-128x128.png',
|
|
sizes: '128x128',
|
|
type: 'image/png'
|
|
},
|
|
{
|
|
src: '/icons/icon-144x144.png',
|
|
sizes: '144x144',
|
|
type: 'image/png'
|
|
},
|
|
{
|
|
src: '/icons/icon-152x152.png',
|
|
sizes: '152x152',
|
|
type: 'image/png'
|
|
},
|
|
{
|
|
src: '/icons/icon-192x192.png',
|
|
sizes: '192x192',
|
|
type: 'image/png'
|
|
},
|
|
{
|
|
src: '/icons/icon-384x384.png',
|
|
sizes: '384x384',
|
|
type: 'image/png'
|
|
},
|
|
{
|
|
src: '/icons/icon-512x512.png',
|
|
sizes: '512x512',
|
|
type: 'image/png'
|
|
}
|
|
]
|
|
},
|
|
workbox: {
|
|
navigateFallback: '/',
|
|
globPatterns: ['**/*.{js,css,html,png,jpg,jpeg,svg,ico}'],
|
|
navigateFallbackDenylist: [/^\/api\//],
|
|
runtimeCaching: [
|
|
{
|
|
urlPattern: /^https:\/\/.*\.monacousa\.org\/.*/i,
|
|
handler: 'NetworkFirst',
|
|
options: {
|
|
cacheName: 'api-cache',
|
|
expiration: {
|
|
maxEntries: 10,
|
|
maxAgeSeconds: 60 * 60 * 24 // 24 hours
|
|
},
|
|
cacheableResponse: {
|
|
statuses: [0, 200]
|
|
}
|
|
}
|
|
}
|
|
],
|
|
skipWaiting: true,
|
|
clientsClaim: true
|
|
},
|
|
client: {
|
|
installPrompt: true,
|
|
periodicSyncForUpdates: 20
|
|
},
|
|
devOptions: {
|
|
enabled: true,
|
|
type: 'module'
|
|
}
|
|
},
|
|
nitro: {
|
|
experimental: {
|
|
wasm: true
|
|
}
|
|
},
|
|
runtimeConfig: {
|
|
// Server-side configuration
|
|
keycloak: {
|
|
issuer: process.env.NUXT_KEYCLOAK_ISSUER || "",
|
|
clientId: process.env.NUXT_KEYCLOAK_CLIENT_ID || "monacousa-portal",
|
|
clientSecret: process.env.NUXT_KEYCLOAK_CLIENT_SECRET || "",
|
|
callbackUrl: process.env.NUXT_KEYCLOAK_CALLBACK_URL || "https://monacousa.org/auth/callback",
|
|
},
|
|
nocodb: {
|
|
url: process.env.NUXT_NOCODB_URL || "",
|
|
token: process.env.NUXT_NOCODB_TOKEN || "",
|
|
baseId: process.env.NUXT_NOCODB_BASE_ID || "",
|
|
},
|
|
minio: {
|
|
endPoint: process.env.NUXT_MINIO_ENDPOINT || "s3.monacousa.org",
|
|
port: parseInt(process.env.NUXT_MINIO_PORT || "443"),
|
|
useSSL: process.env.NUXT_MINIO_USE_SSL !== "false",
|
|
accessKey: process.env.NUXT_MINIO_ACCESS_KEY || "",
|
|
secretKey: process.env.NUXT_MINIO_SECRET_KEY || "",
|
|
bucketName: process.env.NUXT_MINIO_BUCKET_NAME || "monacousa-portal",
|
|
},
|
|
sessionSecret: process.env.NUXT_SESSION_SECRET || "",
|
|
encryptionKey: process.env.NUXT_ENCRYPTION_KEY || "",
|
|
public: {
|
|
// Client-side configuration
|
|
appName: "MonacoUSA Portal",
|
|
domain: process.env.NUXT_PUBLIC_DOMAIN || "monacousa.org",
|
|
},
|
|
},
|
|
vuetify: {
|
|
vuetifyOptions: {
|
|
theme: {
|
|
defaultTheme: "monacousa",
|
|
themes: {
|
|
monacousa: {
|
|
colors: {
|
|
primary: "#a31515",
|
|
secondary: "#ffffff",
|
|
accent: "#f5f5f5",
|
|
error: "#ff5252",
|
|
warning: "#ff9800",
|
|
info: "#2196f3",
|
|
success: "#4caf50",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
### 3.2 Create Environment Configuration (.env.example)
|
|
|
|
```env
|
|
# Keycloak Configuration
|
|
NUXT_KEYCLOAK_ISSUER=https://auth.monacousa.org/realms/monacousa-portal
|
|
NUXT_KEYCLOAK_CLIENT_ID=monacousa-portal
|
|
NUXT_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
|
|
NUXT_KEYCLOAK_CALLBACK_URL=https://monacousa.org/auth/callback
|
|
|
|
# NocoDB Configuration
|
|
NUXT_NOCODB_URL=https://db.monacousa.org
|
|
NUXT_NOCODB_TOKEN=your-nocodb-token
|
|
NUXT_NOCODB_BASE_ID=your-nocodb-base-id
|
|
|
|
# MinIO Configuration
|
|
NUXT_MINIO_ENDPOINT=s3.monacousa.org
|
|
NUXT_MINIO_PORT=443
|
|
NUXT_MINIO_USE_SSL=true
|
|
NUXT_MINIO_ACCESS_KEY=your-minio-access-key
|
|
NUXT_MINIO_SECRET_KEY=your-minio-secret-key
|
|
NUXT_MINIO_BUCKET_NAME=monacousa-portal
|
|
|
|
# Security Configuration
|
|
NUXT_SESSION_SECRET=your-48-character-session-secret-key-here
|
|
NUXT_ENCRYPTION_KEY=your-32-character-encryption-key-here
|
|
|
|
# Public Configuration
|
|
NUXT_PUBLIC_DOMAIN=monacousa.org
|
|
```
|
|
|
|
## Phase 4: Authentication Implementation
|
|
|
|
### 4.1 Create Keycloak Utility (server/utils/keycloak.ts)
|
|
|
|
```typescript
|
|
interface KeycloakConfig {
|
|
issuer: string;
|
|
clientId: string;
|
|
clientSecret: string;
|
|
callbackUrl: string;
|
|
}
|
|
|
|
interface TokenResponse {
|
|
access_token: string;
|
|
refresh_token: string;
|
|
id_token: string;
|
|
token_type: string;
|
|
expires_in: number;
|
|
}
|
|
|
|
interface UserInfo {
|
|
sub: string;
|
|
email: string;
|
|
given_name?: string;
|
|
family_name?: string;
|
|
name?: string;
|
|
groups?: string[];
|
|
tier?: string;
|
|
}
|
|
|
|
export class KeycloakClient {
|
|
private config: KeycloakConfig;
|
|
|
|
constructor(config: KeycloakConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
getAuthUrl(state: string): string {
|
|
const params = new URLSearchParams({
|
|
client_id: this.config.clientId,
|
|
redirect_uri: this.config.callbackUrl,
|
|
response_type: 'code',
|
|
scope: 'openid email profile',
|
|
state,
|
|
});
|
|
|
|
return `${this.config.issuer}/protocol/openid-connect/auth?${params}`;
|
|
}
|
|
|
|
async exchangeCodeForTokens(code: string): Promise<TokenResponse> {
|
|
const response = await fetch(`${this.config.issuer}/protocol/openid-connect/token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
client_id: this.config.clientId,
|
|
client_secret: this.config.clientSecret,
|
|
code,
|
|
redirect_uri: this.config.callbackUrl,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Token exchange failed: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
async getUserInfo(accessToken: string): Promise<UserInfo> {
|
|
const response = await fetch(`${this.config.issuer}/protocol/openid-connect/userinfo`, {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get user info: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
async refreshToken(refreshToken: string): Promise<TokenResponse> {
|
|
const response = await fetch(`${this.config.issuer}/protocol/openid-connect/token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
client_id: this.config.clientId,
|
|
client_secret: this.config.clientSecret,
|
|
refresh_token: refreshToken,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Token refresh failed: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
}
|
|
|
|
export function createKeycloakClient(): KeycloakClient {
|
|
const config = useRuntimeConfig();
|
|
return new KeycloakClient(config.keycloak);
|
|
}
|
|
```
|
|
|
|
### 4.2 Create Session Management (server/utils/session.ts)
|
|
|
|
```typescript
|
|
import { serialize, parse } from 'cookie';
|
|
import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
|
|
|
interface SessionData {
|
|
user: {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
groups?: string[];
|
|
tier?: string;
|
|
};
|
|
tokens: {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
expiresAt: number;
|
|
};
|
|
createdAt: number;
|
|
lastActivity: number;
|
|
}
|
|
|
|
export class SessionManager {
|
|
private encryptionKey: Buffer;
|
|
private cookieName = 'monacousa-session';
|
|
|
|
constructor(encryptionKey: string) {
|
|
this.encryptionKey = Buffer.from(encryptionKey, 'hex');
|
|
}
|
|
|
|
private encrypt(data: string): string {
|
|
const iv = randomBytes(16);
|
|
const cipher = createCipheriv('aes-256-cbc', this.encryptionKey, iv);
|
|
let encrypted = cipher.update(data, 'utf8', 'hex');
|
|
encrypted += cipher.final('hex');
|
|
return iv.toString('hex') + ':' + encrypted;
|
|
}
|
|
|
|
private decrypt(encryptedData: string): string {
|
|
const [ivHex, encrypted] = encryptedData.split(':');
|
|
const iv = Buffer.from(ivHex, 'hex');
|
|
const decipher = createDecipheriv('aes-256-cbc', this.encryptionKey, iv);
|
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
decrypted += decipher.final('utf8');
|
|
return decrypted;
|
|
}
|
|
|
|
createSession(sessionData: SessionData): string {
|
|
const data = JSON.stringify(sessionData);
|
|
const encrypted = this.encrypt(data);
|
|
|
|
return serialize(this.cookieName, encrypted, {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'lax',
|
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
path: '/',
|
|
});
|
|
}
|
|
|
|
getSession(cookieHeader?: string): SessionData | null {
|
|
if (!cookieHeader) return null;
|
|
|
|
const cookies = parse(cookieHeader);
|
|
const sessionCookie = cookies[this.cookieName];
|
|
|
|
if (!sessionCookie) return null;
|
|
|
|
try {
|
|
const decrypted = this.decrypt(sessionCookie);
|
|
const sessionData = JSON.parse(decrypted) as SessionData;
|
|
|
|
// Check if session is expired
|
|
if (Date.now() > sessionData.tokens.expiresAt) {
|
|
return null;
|
|
}
|
|
|
|
return sessionData;
|
|
} catch (error) {
|
|
console.error('Failed to decrypt session:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
destroySession(): string {
|
|
return serialize(this.cookieName, '', {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'lax',
|
|
maxAge: 0,
|
|
path: '/',
|
|
});
|
|
}
|
|
}
|
|
|
|
export function createSessionManager(): SessionManager {
|
|
const config = useRuntimeConfig();
|
|
return new SessionManager(config.encryptionKey);
|
|
}
|
|
```
|
|
|
|
### 4.3 Create Authentication Middleware (middleware/auth.ts)
|
|
|
|
```typescript
|
|
export default defineNuxtRouteMiddleware((to) => {
|
|
// Skip auth for public pages
|
|
if (to.meta.auth === false) {
|
|
return;
|
|
}
|
|
|
|
// Check if user is authenticated
|
|
const authState = useState('auth.state', () => ({
|
|
authenticated: false,
|
|
user: null,
|
|
groups: [],
|
|
}));
|
|
|
|
if (!authState.value.authenticated) {
|
|
return navigateTo('/login');
|
|
}
|
|
});
|
|
```
|
|
|
|
### 4.4 Create Auth API Routes
|
|
|
|
#### server/api/auth/login.get.ts
|
|
|
|
```typescript
|
|
export default defineEventHandler(async (event) => {
|
|
const keycloak = createKeycloakClient();
|
|
const state = randomBytes(32).toString('hex');
|
|
|
|
// Store state in session for verification
|
|
setCookie(event, 'oauth-state', state, {
|
|
httpOnly: true,
|
|
secure: true,
|
|
maxAge: 600, // 10 minutes
|
|
});
|
|
|
|
const authUrl = keycloak.getAuthUrl(state);
|
|
|
|
return sendRedirect(event, authUrl);
|
|
});
|
|
```
|
|
|
|
#### server/api/auth/callback.get.ts
|
|
|
|
```typescript
|
|
export default defineEventHandler(async (event) => {
|
|
const query = getQuery(event);
|
|
const { code, state } = query;
|
|
|
|
if (!code || !state) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: 'Missing authorization code or state',
|
|
});
|
|
}
|
|
|
|
// Verify state
|
|
const storedState = getCookie(event, 'oauth-state');
|
|
if (state !== storedState) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: 'Invalid state parameter',
|
|
});
|
|
}
|
|
|
|
try {
|
|
const keycloak = createKeycloakClient();
|
|
const sessionManager = createSessionManager();
|
|
|
|
// Exchange code for tokens
|
|
const tokens = await keycloak.exchangeCodeForTokens(code as string);
|
|
|
|
// Get user info
|
|
const userInfo = await keycloak.getUserInfo(tokens.access_token);
|
|
|
|
// Create session
|
|
const sessionData = {
|
|
user: {
|
|
id: userInfo.sub,
|
|
email: userInfo.email,
|
|
name: userInfo.name || `${userInfo.given_name} ${userInfo.family_name}`.trim(),
|
|
groups: userInfo.groups || [],
|
|
tier: userInfo.tier,
|
|
},
|
|
tokens: {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token,
|
|
expiresAt: Date.now() + (tokens.expires_in * 1000),
|
|
},
|
|
createdAt: Date.now(),
|
|
lastActivity: Date.now(),
|
|
};
|
|
|
|
const sessionCookie = sessionManager.createSession(sessionData);
|
|
|
|
// Set session cookie
|
|
setHeader(event, 'Set-Cookie', sessionCookie);
|
|
|
|
// Clear state cookie
|
|
deleteCookie(event, 'oauth-state');
|
|
|
|
return sendRedirect(event, '/dashboard');
|
|
} catch (error) {
|
|
console.error('Auth callback error:', error);
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: 'Authentication failed',
|
|
});
|
|
}
|
|
});
|
|
```
|
|
|
|
#### server/api/auth/session.get.ts
|
|
|
|
```typescript
|
|
export default defineEventHandler(async (event) => {
|
|
const sessionManager = createSessionManager();
|
|
const cookieHeader = getHeader(event, 'cookie');
|
|
const session = sessionManager.getSession(cookieHeader);
|
|
|
|
if (!session) {
|
|
return {
|
|
authenticated: false,
|
|
user: null,
|
|
groups: [],
|
|
};
|
|
}
|
|
|
|
return {
|
|
authenticated: true,
|
|
user: session.user,
|
|
groups: session.user.groups || [],
|
|
};
|
|
});
|
|
```
|
|
|
|
#### server/api/auth/logout.post.ts
|
|
|
|
```typescript
|
|
export default defineEventHandler(async (event) => {
|
|
const sessionManager = createSessionManager();
|
|
const destroyCookie = sessionManager.destroySession();
|
|
|
|
setHeader(event, 'Set-Cookie', destroyCookie);
|
|
|
|
return { success: true };
|
|
});
|
|
```
|
|
|
|
## Phase 5: Database Integration (NocoDB)
|
|
|
|
### 5.1 Create NocoDB Utility (server/utils/nocodb.ts)
|
|
|
|
```typescript
|
|
interface NocoDBConfig {
|
|
url: string;
|
|
token: string;
|
|
baseId: string;
|
|
}
|
|
|
|
export class NocoDBClient {
|
|
private config: NocoDBConfig;
|
|
private baseUrl: string;
|
|
|
|
constructor(config: NocoDBConfig) {
|
|
this.config = config;
|
|
this.baseUrl = `${config.url}/api/v2/tables`;
|
|
}
|
|
|
|
private async request(endpoint: string, options: RequestInit = {}) {
|
|
const url = `${this.baseUrl}${endpoint}`;
|
|
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
'xc-token': this.config.token,
|
|
'Content-Type': 'application/json',
|
|
...options.headers,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`NocoDB request failed: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
async findAll(tableName: string, params: Record<string, any> = {}) {
|
|
const queryString = new URLSearchParams(params).toString();
|
|
const endpoint = `/${tableName}/records${queryString ? `?${queryString}` : ''}`;
|
|
return this.request(endpoint);
|
|
}
|
|
|
|
async findOne(tableName: string, id: string) {
|
|
return this.request(`/${tableName}/records/${id}`);
|
|
}
|
|
|
|
async create(tableName: string, data: Record<string, any>) {
|
|
return this.request(`/${tableName}/records`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async update(tableName: string, id: string, data: Record<string, any>) {
|
|
return this.request(`/${tableName}/records/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async delete(tableName: string, id: string) {
|
|
return this.request(`/${tableName}/records/${id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
}
|
|
|
|
export function createNocoDBClient(): NocoDBClient {
|
|
const config = useRuntimeConfig();
|
|
return new NocoDBClient(config.nocodb);
|
|
}
|
|
```
|
|
|
|
### 5.2 Create Database API Template (server/api/data/[table]/[...params].ts)
|
|
|
|
```typescript
|
|
export default defineEventHandler(async (event) => {
|
|
const method = getMethod(event);
|
|
const params = getRouterParams(event);
|
|
const table = params.table;
|
|
const additionalParams = params.params?.split('/') || [];
|
|
|
|
const nocodb = createNocoDBClient();
|
|
|
|
try {
|
|
switch (method) {
|
|
case 'GET':
|
|
if (additionalParams.length > 0) {
|
|
// Get single record
|
|
const id = additionalParams[0];
|
|
return await nocodb.findOne(table, id);
|
|
} else {
|
|
// Get all records
|
|
const query = getQuery(event);
|
|
return await nocodb.findAll(table, query);
|
|
}
|
|
|
|
case 'POST':
|
|
const createData = await readBody(event);
|
|
return await nocodb.create(table, createData);
|
|
|
|
case 'PATCH':
|
|
if (additionalParams.length === 0) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: 'Record ID required for update',
|
|
});
|
|
}
|
|
const updateId = additionalParams[0];
|
|
const updateData = await readBody(event);
|
|
return await nocodb.update(table, updateId, updateData);
|
|
|
|
case 'DELETE':
|
|
if (additionalParams.length === 0) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: 'Record ID required for delete',
|
|
});
|
|
}
|
|
const deleteId = additionalParams[0];
|
|
return await nocodb.delete(table, deleteId);
|
|
|
|
default:
|
|
throw createError({
|
|
statusCode: 405,
|
|
statusMessage: 'Method not allowed',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Database operation error:', error);
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: 'Database operation failed',
|
|
});
|
|
}
|
|
});
|
|
```
|
|
|
|
## Phase 6: File Storage Integration (MinIO)
|
|
|
|
### 6.1 Create MinIO Utility (server/utils/minio.ts)
|
|
|
|
```typescript
|
|
import { Client } from 'minio';
|
|
|
|
interface MinIOConfig {
|
|
endPoint: string;
|
|
port: number;
|
|
useSSL: boolean;
|
|
accessKey: string;
|
|
secretKey: string;
|
|
bucketName: string;
|
|
}
|
|
|
|
export class MinIOClient {
|
|
private client: Client;
|
|
private bucketName: string;
|
|
|
|
constructor(config: MinIOConfig) {
|
|
this.client = new Client({
|
|
endPoint: config.endPoint,
|
|
port: config.port,
|
|
useSSL: config.useSSL,
|
|
accessKey: config.accessKey,
|
|
secretKey: config.secretKey,
|
|
});
|
|
this.bucketName = config.bucketName;
|
|
}
|
|
|
|
async ensureBucket(): Promise<void> {
|
|
const exists = await this.client.bucketExists(this.bucketName);
|
|
if (!exists) {
|
|
await this.client.makeBucket(this.bucketName);
|
|
}
|
|
}
|
|
|
|
async uploadFile(fileName: string, buffer: Buffer, contentType?: string): Promise<string> {
|
|
await this.ensureBucket();
|
|
|
|
const metadata = contentType ? { 'Content-Type': contentType } : {};
|
|
|
|
await this.client.putObject(this.bucketName, fileName, buffer, buffer.length, metadata);
|
|
|
|
return fileName;
|
|
}
|
|
|
|
async getFile(fileName: string): Promise<Buffer> {
|
|
const stream = await this.client.getObject(this.bucketName, fileName);
|
|
const chunks: Buffer[] = [];
|
|
|
|
return new Promise((resolve, reject) => {
|
|
stream.on('data', (chunk) => chunks.push(chunk));
|
|
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
|
stream.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async deleteFile(fileName: string): Promise<void> {
|
|
await this.client.removeObject(this.bucketName, fileName);
|
|
}
|
|
|
|
async listFiles(prefix?: string): Promise<string[]> {
|
|
const objects: string[] = [];
|
|
const stream = this.client.listObjects(this.bucketName, prefix);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
stream.on('data', (obj) => objects.push(obj.name!));
|
|
stream.on('end', () => resolve(objects));
|
|
stream.on('error', reject);
|
|
});
|
|
}
|
|
|
|
getPresignedUrl(fileName: string, expiry: number = 3600): Promise<string> {
|
|
return this.client.presignedGetObject(this.bucketName, fileName, expiry);
|
|
}
|
|
}
|
|
|
|
export function createMinIOClient(): MinIOClient {
|
|
const config = useRuntimeConfig();
|
|
return new MinIOClient(config.minio);
|
|
}
|
|
```
|
|
|
|
### 6.2 Create File Upload API (server/api/files/upload.post.ts)
|
|
|
|
```typescript
|
|
import formidable from 'formidable';
|
|
import { readFileSync } from 'fs';
|
|
import { lookup } from 'mime-types';
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const form = formidable({
|
|
maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
allowEmptyFiles: false,
|
|
});
|
|
|
|
try {
|
|
const [fields, files] = await form.parse(event.node.req);
|
|
const minio = createMinIOClient();
|
|
|
|
const uploadedFiles = [];
|
|
|
|
for (const [fieldName, fileArray] of Object.entries(files)) {
|
|
const file = Array.isArray(fileArray) ? fileArray[0] : fileArray;
|
|
|
|
if (file && file.filepath) {
|
|
const buffer = readFileSync(file.filepath);
|
|
const contentType = lookup(file.originalFilename || '') || 'application/octet-stream';
|
|
const fileName = `${Date.now()}-${file.originalFilename}`;
|
|
|
|
await minio.uploadFile(fileName, buffer, contentType);
|
|
|
|
uploadedFiles.push({
|
|
fieldName,
|
|
fileName,
|
|
originalName: file.originalFilename,
|
|
size: file.size,
|
|
contentType,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { success: true, files: uploadedFiles };
|
|
} catch (error) {
|
|
console.error('File upload error:', error);
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: 'File upload failed',
|
|
});
|
|
}
|
|
});
|
|
```
|
|
|
|
### 6.3 Create File Download API (server/api/files/[filename].get.ts)
|
|
|
|
```typescript
|
|
export default defineEventHandler(async (event) => {
|
|
const filename = getRouterParam(event, 'filename');
|
|
|
|
if (!filename) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: 'Filename required',
|
|
});
|
|
}
|
|
|
|
try {
|
|
const minio = createMinIOClient();
|
|
const buffer = await minio.getFile(filename);
|
|
|
|
// Set appropriate headers
|
|
setHeader(event, 'Content-Type', 'application/octet-stream');
|
|
setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`);
|
|
|
|
return buffer;
|
|
} catch (error) {
|
|
console.error('File download error:', error);
|
|
throw createError({
|
|
statusCode: 404,
|
|
statusMessage: 'File not found',
|
|
});
|
|
}
|
|
});
|
|
```
|
|
|
|
## Phase 7: UI Components and Layout
|
|
|
|
### 7.1 Create Authentication Composable (composables/useAuth.ts)
|
|
|
|
```typescript
|
|
interface User {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
groups?: string[];
|
|
tier?: string;
|
|
}
|
|
|
|
interface AuthState {
|
|
authenticated: boolean;
|
|
user: User | null;
|
|
groups: string[];
|
|
}
|
|
|
|
export const useAuth = () => {
|
|
const authState = useState<AuthState>('auth.state', () => ({
|
|
authenticated: false,
|
|
user: null,
|
|
groups: [],
|
|
}));
|
|
|
|
const login = () => {
|
|
return navigateTo('/api/auth/login');
|
|
};
|
|
|
|
const logout = async () => {
|
|
try {
|
|
await $fetch('/api/auth/logout', { method: 'POST' });
|
|
authState.value = {
|
|
authenticated: false,
|
|
user: null,
|
|
groups: [],
|
|
};
|
|
await navigateTo('/login');
|
|
} catch (error) {
|
|
console.error('Logout error:', error);
|
|
await navigateTo('/login');
|
|
}
|
|
};
|
|
|
|
const checkAuth = async () => {
|
|
try {
|
|
const response = await $fetch<AuthState>('/api/auth/session');
|
|
authState.value = response;
|
|
return response.authenticated;
|
|
} catch (error) {
|
|
console.error('Auth check error:', error);
|
|
authState.value = {
|
|
authenticated: false,
|
|
user: null,
|
|
groups: [],
|
|
};
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const isAdmin = computed(() => {
|
|
return authState.value.groups?.includes('admin') || false;
|
|
});
|
|
|
|
const hasRole = (role: string) => {
|
|
return authState.value.groups?.includes(role) || false;
|
|
};
|
|
|
|
return {
|
|
authState: readonly(authState),
|
|
user: computed(() => authState.value.user),
|
|
authenticated: computed(() => authState.value.authenticated),
|
|
groups: computed(() => authState.value.groups),
|
|
isAdmin,
|
|
hasRole,
|
|
login,
|
|
logout,
|
|
checkAuth,
|
|
};
|
|
};
|
|
```
|
|
|
|
### 7.2 Create Login Page (pages/login.vue)
|
|
|
|
```vue
|
|
<template>
|
|
<v-app>
|
|
<v-main class="d-flex align-center justify-center min-h-screen bg-grey-lighten-4">
|
|
<v-container>
|
|
<v-row justify="center">
|
|
<v-col cols="12" sm="8" md="6" lg="4">
|
|
<v-card class="elevation-8 rounded-lg">
|
|
<v-card-text class="pa-8">
|
|
<div class="text-center mb-6">
|
|
<v-img
|
|
src="/logo.png"
|
|
alt="MonacoUSA"
|
|
max-width="120"
|
|
class="mx-auto mb-4"
|
|
/>
|
|
<h1 class="text-h4 font-weight-bold text-primary mb-2">
|
|
MonacoUSA Portal
|
|
</h1>
|
|
<p class="text-body-1 text-grey-600">
|
|
Sign in to access your dashboard
|
|
</p>
|
|
</div>
|
|
|
|
<v-btn
|
|
@click="handleLogin"
|
|
:loading="loading"
|
|
color="primary"
|
|
size="large"
|
|
block
|
|
class="mb-4"
|
|
prepend-icon="mdi-login"
|
|
>
|
|
Sign In with SSO
|
|
</v-btn>
|
|
|
|
<div class="text-center">
|
|
<p class="text-caption text-grey-600">
|
|
Secure authentication powered by Keycloak
|
|
</p>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</v-container>
|
|
</v-main>
|
|
</v-app>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({
|
|
auth: false,
|
|
layout: false,
|
|
});
|
|
|
|
const { login } = useAuth();
|
|
const loading = ref(false);
|
|
|
|
const handleLogin = async () => {
|
|
loading.value = true;
|
|
try {
|
|
await login();
|
|
} catch (error) {
|
|
console.error('Login error:', error);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
</script>
|
|
```
|
|
|
|
### 7.3 Create Auth Callback Page (pages/auth/callback.vue)
|
|
|
|
```vue
|
|
<template>
|
|
<v-app>
|
|
<v-main class="d-flex align-center justify-center min-h-screen">
|
|
<v-container>
|
|
<v-row justify="center">
|
|
<v-col cols="12" sm="6" md="4">
|
|
<v-card class="text-center pa-8">
|
|
<v-progress-circular
|
|
indeterminate
|
|
color="primary"
|
|
size="64"
|
|
class="mb-4"
|
|
/>
|
|
<h2 class="text-h5 mb-2">Signing you in...</h2>
|
|
<p class="text-body-2 text-grey-600">
|
|
Please wait while we complete your authentication.
|
|
</p>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</v-container>
|
|
</v-main>
|
|
</v-app>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({
|
|
auth: false,
|
|
layout: false,
|
|
});
|
|
|
|
// The actual authentication is handled by the server-side callback API
|
|
// This page just shows a loading state while the redirect happens
|
|
</script>
|
|
```
|
|
|
|
### 7.4 Create Dashboard Layout (layouts/dashboard.vue)
|
|
|
|
```vue
|
|
<template>
|
|
<v-app>
|
|
<v-navigation-drawer
|
|
v-model="drawer"
|
|
:rail="rail && !mdAndDown"
|
|
permanent
|
|
color="white"
|
|
class="elevation-2"
|
|
>
|
|
<!-- Logo and Title -->
|
|
<v-list>
|
|
<v-list-item
|
|
class="px-3 py-3"
|
|
:class="{ 'cursor-pointer': !mdAndDown }"
|
|
@click="!mdAndDown ? (rail = !rail) : undefined"
|
|
>
|
|
<template v-slot:prepend>
|
|
<v-avatar size="40" class="me-3">
|
|
<v-img src="/logo.png" alt="MonacoUSA" />
|
|
</v-avatar>
|
|
</template>
|
|
<v-list-item-title v-if="!rail || mdAndDown" class="text-subtitle-1 font-weight-medium">
|
|
MonacoUSA Portal
|
|
</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
|
|
<v-divider></v-divider>
|
|
|
|
<!-- Navigation Items -->
|
|
<v-list density="compact" nav>
|
|
<v-list-item
|
|
v-for="item in navigationItems"
|
|
:key="item.to"
|
|
:prepend-icon="item.icon"
|
|
:title="item.label"
|
|
:value="item.to"
|
|
:to="item.to"
|
|
color="primary"
|
|
rounded="xl"
|
|
class="mx-1"
|
|
></v-list-item>
|
|
|
|
<!-- Admin Section -->
|
|
<template v-if="isAdmin">
|
|
<v-divider class="my-2"></v-divider>
|
|
<v-list-subheader class="text-caption text-grey-600 px-3">
|
|
Administration
|
|
</v-list-subheader>
|
|
<v-list-item
|
|
v-for="item in adminItems"
|
|
:key="item.to"
|
|
:prepend-icon="item.icon"
|
|
:title="item.label"
|
|
:value="item.to"
|
|
:to="item.to"
|
|
color="primary"
|
|
rounded="xl"
|
|
class="mx-1"
|
|
></v-list-item>
|
|
</template>
|
|
</v-list>
|
|
|
|
<template v-slot:append>
|
|
<v-divider></v-divider>
|
|
|
|
<!-- User Info Section -->
|
|
<div class="pa-2">
|
|
<v-list v-if="user">
|
|
<v-list-item
|
|
:prepend-avatar="`https://ui-avatars.com/api/?name=${encodeURIComponent(user.name || user.email)}&background=a31515&color=fff`"
|
|
:title="user.name || user.email"
|
|
:subtitle="user.email"
|
|
class="px-2"
|
|
>
|
|
<template v-slot:append v-if="!rail && groups.length">
|
|
<div>
|
|
<v-chip
|
|
v-if="groups.includes('admin')"
|
|
size="small"
|
|
color="primary"
|
|
variant="tonal"
|
|
>
|
|
Admin
|
|
</v-chip>
|
|
<v-chip
|
|
v-else-if="groups.includes('manager')"
|
|
size="small"
|
|
color="success"
|
|
variant="tonal"
|
|
>
|
|
Manager
|
|
</v-chip>
|
|
</div>
|
|
</template>
|
|
</v-list-item>
|
|
|
|
<v-list-item
|
|
@click="handleLogout"
|
|
prepend-icon="mdi-logout"
|
|
title="Logout"
|
|
class="px-2 mt-1"
|
|
base-color="error"
|
|
rounded="xl"
|
|
></v-list-item>
|
|
</v-list>
|
|
</div>
|
|
</template>
|
|
</v-navigation-drawer>
|
|
|
|
<v-app-bar
|
|
flat
|
|
color="white"
|
|
class="border-b"
|
|
>
|
|
<v-app-bar-nav-icon
|
|
@click="drawer = !drawer"
|
|
class="d-lg-none"
|
|
></v-app-bar-nav-icon>
|
|
|
|
<v-toolbar-title class="text-h6">
|
|
{{ pageTitle }}
|
|
</v-toolbar-title>
|
|
|
|
<v-spacer></v-spacer>
|
|
|
|
<!-- PWA Install Button -->
|
|
<v-btn
|
|
v-if="canInstall"
|
|
@click="install"
|
|
icon="mdi-download"
|
|
variant="text"
|
|
class="me-2"
|
|
>
|
|
<v-icon>mdi-download</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">
|
|
Install App
|
|
</v-tooltip>
|
|
</v-btn>
|
|
</v-app-bar>
|
|
|
|
<v-main class="bg-grey-lighten-4">
|
|
<slot />
|
|
</v-main>
|
|
</v-app>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useDisplay } from 'vuetify';
|
|
|
|
const route = useRoute();
|
|
const { mdAndDown } = useDisplay();
|
|
const { user, groups, isAdmin, logout } = useAuth();
|
|
|
|
// Sidebar state
|
|
const drawer = ref(true);
|
|
const rail = ref(false);
|
|
|
|
// PWA install
|
|
const canInstall = ref(false);
|
|
let deferredPrompt: any = null;
|
|
|
|
// Page title based on current route
|
|
const pageTitle = computed(() => {
|
|
const routeName = route.name as string;
|
|
const pageTitles: Record<string, string> = {
|
|
'dashboard': 'Dashboard',
|
|
'dashboard-index': 'Dashboard',
|
|
'dashboard-tools': 'Tools',
|
|
'dashboard-settings': 'Settings',
|
|
'dashboard-admin': 'Administration',
|
|
};
|
|
|
|
return pageTitles[routeName] || 'Dashboard';
|
|
});
|
|
|
|
// Main navigation items
|
|
const navigationItems = computed(() => {
|
|
return [
|
|
{
|
|
label: 'Dashboard',
|
|
icon: 'mdi-view-dashboard',
|
|
to: '/dashboard',
|
|
},
|
|
{
|
|
label: 'Tools',
|
|
icon: 'mdi-tools',
|
|
to: '/dashboard/tools',
|
|
},
|
|
{
|
|
label: 'Settings',
|
|
icon: 'mdi-cog',
|
|
to: '/dashboard/settings',
|
|
},
|
|
];
|
|
});
|
|
|
|
// Admin navigation items
|
|
const adminItems = computed(() => {
|
|
return [
|
|
{
|
|
label: 'Admin Panel',
|
|
icon: 'mdi-shield-crown',
|
|
to: '/dashboard/admin',
|
|
},
|
|
];
|
|
});
|
|
|
|
// Logout handler
|
|
const handleLogout = async () => {
|
|
await logout();
|
|
};
|
|
|
|
// PWA install handler
|
|
const install = async () => {
|
|
if (deferredPrompt) {
|
|
deferredPrompt.prompt();
|
|
const { outcome } = await deferredPrompt.userChoice;
|
|
if (outcome === 'accepted') {
|
|
canInstall.value = false;
|
|
}
|
|
deferredPrompt = null;
|
|
}
|
|
};
|
|
|
|
// Initialize drawer state on mobile
|
|
onMounted(() => {
|
|
if (mdAndDown.value) {
|
|
drawer.value = false;
|
|
}
|
|
|
|
// PWA install prompt
|
|
window.addEventListener('beforeinstallprompt', (e) => {
|
|
e.preventDefault();
|
|
deferredPrompt = e;
|
|
canInstall.value = true;
|
|
});
|
|
|
|
window.addEventListener('appinstalled', () => {
|
|
canInstall.value = false;
|
|
deferredPrompt = null;
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.border-b {
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.12) !important;
|
|
}
|
|
|
|
.cursor-pointer {
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Improve rail mode appearance */
|
|
.v-navigation-drawer--rail {
|
|
width: 72px !important;
|
|
}
|
|
|
|
.v-navigation-drawer--rail .v-list-item {
|
|
padding-inline-start: 16px !important;
|
|
padding-inline-end: 16px !important;
|
|
justify-content: center;
|
|
min-height: 48px;
|
|
}
|
|
|
|
.v-navigation-drawer--rail .v-list-item__prepend {
|
|
margin-inline-end: 0 !important;
|
|
justify-content: center;
|
|
}
|
|
|
|
.v-navigation-drawer--rail .v-list-item__content {
|
|
display: none;
|
|
}
|
|
|
|
.v-navigation-drawer--rail .v-list-item__append {
|
|
display: none;
|
|
}
|
|
|
|
/* Mobile responsiveness */
|
|
@media (max-width: 960px) {
|
|
.v-navigation-drawer {
|
|
position: fixed !important;
|
|
z-index: 1004 !important;
|
|
}
|
|
|
|
.v-main {
|
|
padding-left: 0 !important;
|
|
}
|
|
}
|
|
|
|
/* PWA optimizations */
|
|
@media (display-mode: standalone) {
|
|
.v-app-bar {
|
|
padding-top: env(safe-area-inset-top);
|
|
}
|
|
|
|
.v-navigation-drawer {
|
|
padding-bottom: env(safe-area-inset-bottom);
|
|
}
|
|
}
|
|
</style>
|
|
```
|
|
|
|
### 7.5 Create Dashboard Index Page (pages/dashboard/index.vue)
|
|
|
|
```vue
|
|
<template>
|
|
<v-container fluid class="pa-6">
|
|
<v-row>
|
|
<v-col cols="12">
|
|
<div class="d-flex align-center mb-6">
|
|
<div>
|
|
<h1 class="text-h4 font-weight-bold text-primary mb-2">
|
|
Welcome back, {{ user?.name || 'User' }}!
|
|
</h1>
|
|
<p class="text-body-1 text-grey-600">
|
|
Here's what's happening with your MonacoUSA Portal today.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-row>
|
|
<!-- Quick Stats -->
|
|
<v-col cols="12" sm="6" md="3">
|
|
<v-card class="pa-4 text-center" color="primary" variant="tonal">
|
|
<v-icon size="48" class="mb-2">mdi-account-group</v-icon>
|
|
<div class="text-h5 font-weight-bold">{{ stats.users }}</div>
|
|
<div class="text-body-2">Active Users</div>
|
|
</v-card>
|
|
</v-col>
|
|
|
|
<v-col cols="12" sm="6" md="3">
|
|
<v-card class="pa-4 text-center" color="success" variant="tonal">
|
|
<v-icon size="48" class="mb-2">mdi-tools</v-icon>
|
|
<div class="text-h5 font-weight-bold">{{ stats.tools }}</div>
|
|
<div class="text-body-2">Available Tools</div>
|
|
</v-card>
|
|
</v-col>
|
|
|
|
<v-col cols="12" sm="6" md="3">
|
|
<v-card class="pa-4 text-center" color="info" variant="tonal">
|
|
<v-icon size="48" class="mb-2">mdi-file-document</v-icon>
|
|
<div class="text-h5 font-weight-bold">{{ stats.documents }}</div>
|
|
<div class="text-body-2">Documents</div>
|
|
</v-card>
|
|
</v-col>
|
|
|
|
<v-col cols="12" sm="6" md="3">
|
|
<v-card class="pa-4 text-center" color="warning" variant="tonal">
|
|
<v-icon size="48" class="mb-2">mdi-chart-line</v-icon>
|
|
<div class="text-h5 font-weight-bold">{{ stats.activity }}</div>
|
|
<div class="text-body-2">Recent Activity</div>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-row>
|
|
<!-- Recent Activity -->
|
|
<v-col cols="12" md="8">
|
|
<v-card>
|
|
<v-card-title class="d-flex align-center">
|
|
<v-icon class="me-2">mdi-history</v-icon>
|
|
Recent Activity
|
|
</v-card-title>
|
|
<v-card-text>
|
|
<v-list>
|
|
<v-list-item
|
|
v-for="activity in recentActivity"
|
|
:key="activity.id"
|
|
:prepend-icon="activity.icon"
|
|
:title="activity.title"
|
|
:subtitle="activity.description"
|
|
>
|
|
<template v-slot:append>
|
|
<div class="text-caption text-grey-600">
|
|
{{ formatDate(activity.timestamp) }}
|
|
</div>
|
|
</template>
|
|
</v-list-item>
|
|
</v-list>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
|
|
<!-- Quick Actions -->
|
|
<v-col cols="12" md="4">
|
|
<v-card>
|
|
<v-card-title class="d-flex align-center">
|
|
<v-icon class="me-2">mdi-lightning-bolt</v-icon>
|
|
Quick Actions
|
|
</v-card-title>
|
|
<v-card-text>
|
|
<v-list>
|
|
<v-list-item
|
|
v-for="action in quickActions"
|
|
:key="action.title"
|
|
:prepend-icon="action.icon"
|
|
:title="action.title"
|
|
:subtitle="action.description"
|
|
:to="action.to"
|
|
class="rounded"
|
|
/>
|
|
</v-list>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</v-container>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({
|
|
layout: 'dashboard',
|
|
});
|
|
|
|
const { user } = useAuth();
|
|
|
|
// Mock data - replace with real API calls
|
|
const stats = ref({
|
|
users: 42,
|
|
tools: 8,
|
|
documents: 156,
|
|
activity: 23,
|
|
});
|
|
|
|
const recentActivity = ref([
|
|
{
|
|
id: 1,
|
|
icon: 'mdi-account-plus',
|
|
title: 'New user registered',
|
|
description: 'John Doe joined the portal',
|
|
timestamp: new Date(Date.now() - 1000 * 60 * 30), // 30 minutes ago
|
|
},
|
|
{
|
|
id: 2,
|
|
icon: 'mdi-file-upload',
|
|
title: 'Document uploaded',
|
|
description: 'Annual report uploaded to documents',
|
|
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago
|
|
},
|
|
{
|
|
id: 3,
|
|
icon: 'mdi-cog',
|
|
title: 'Settings updated',
|
|
description: 'Portal configuration modified',
|
|
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24), // 1 day ago
|
|
},
|
|
]);
|
|
|
|
const quickActions = ref([
|
|
{
|
|
icon: 'mdi-tools',
|
|
title: 'Browse Tools',
|
|
description: 'Access available tools',
|
|
to: '/dashboard/tools',
|
|
},
|
|
{
|
|
icon: 'mdi-file-upload',
|
|
title: 'Upload Document',
|
|
description: 'Add new files',
|
|
to: '/dashboard/files',
|
|
},
|
|
{
|
|
icon: 'mdi-cog',
|
|
title: 'Settings',
|
|
description: 'Configure your account',
|
|
to: '/dashboard/settings',
|
|
},
|
|
]);
|
|
|
|
const formatDate = (date: Date) => {
|
|
const now = new Date();
|
|
const diff = now.getTime() - date.getTime();
|
|
const minutes = Math.floor(diff / (1000 * 60));
|
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
|
|
if (minutes < 60) {
|
|
return `${minutes}m ago`;
|
|
} else if (hours < 24) {
|
|
return `${hours}h ago`;
|
|
} else {
|
|
return `${days}d ago`;
|
|
}
|
|
};
|
|
</script>
|
|
```
|
|
|
|
### 7.6 Create Tools Page Template (pages/dashboard/tools.vue)
|
|
|
|
```vue
|
|
<template>
|
|
<v-container fluid class="pa-6">
|
|
<v-row>
|
|
<v-col cols="12">
|
|
<div class="d-flex align-center justify-space-between mb-6">
|
|
<div>
|
|
<h1 class="text-h4 font-weight-bold text-primary mb-2">
|
|
Tools & Services
|
|
</h1>
|
|
<p class="text-body-1 text-grey-600">
|
|
Access your available tools and services
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-row>
|
|
<v-col
|
|
v-for="tool in tools"
|
|
:key="tool.id"
|
|
cols="12"
|
|
sm="6"
|
|
md="4"
|
|
lg="3"
|
|
>
|
|
<v-card
|
|
class="h-100 d-flex flex-column"
|
|
:class="{ 'elevation-8': tool.featured }"
|
|
hover
|
|
>
|
|
<v-card-text class="flex-grow-1 d-flex flex-column">
|
|
<div class="text-center mb-4">
|
|
<v-avatar
|
|
:color="tool.color"
|
|
size="64"
|
|
class="mb-3"
|
|
>
|
|
<v-icon size="32" color="white">{{ tool.icon }}</v-icon>
|
|
</v-avatar>
|
|
<h3 class="text-h6 font-weight-bold mb-2">{{ tool.name }}</h3>
|
|
<p class="text-body-2 text-grey-600">{{ tool.description }}</p>
|
|
</div>
|
|
|
|
<v-spacer />
|
|
|
|
<div class="d-flex align-center justify-space-between">
|
|
<v-chip
|
|
:color="tool.status === 'active' ? 'success' : 'warning'"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ tool.status }}
|
|
</v-chip>
|
|
<v-btn
|
|
:to="tool.link"
|
|
color="primary"
|
|
variant="tonal"
|
|
size="small"
|
|
>
|
|
Open
|
|
</v-btn>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Coming Soon Section -->
|
|
<v-row v-if="comingSoonTools.length > 0">
|
|
<v-col cols="12">
|
|
<h2 class="text-h5 font-weight-bold text-primary mb-4 mt-6">
|
|
Coming Soon
|
|
</h2>
|
|
</v-col>
|
|
<v-col
|
|
v-for="tool in comingSoonTools"
|
|
:key="tool.id"
|
|
cols="12"
|
|
sm="6"
|
|
md="4"
|
|
lg="3"
|
|
>
|
|
<v-card class="h-100" disabled>
|
|
<v-card-text class="text-center">
|
|
<v-avatar
|
|
color="grey-lighten-2"
|
|
size="64"
|
|
class="mb-3"
|
|
>
|
|
<v-icon size="32" color="grey">{{ tool.icon }}</v-icon>
|
|
</v-avatar>
|
|
<h3 class="text-h6 font-weight-bold mb-2 text-grey">{{ tool.name }}</h3>
|
|
<p class="text-body-2 text-grey-600">{{ tool.description }}</p>
|
|
<v-chip color="grey" size="small" variant="tonal" class="mt-2">
|
|
Coming Soon
|
|
</v-chip>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</v-container>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({
|
|
layout: 'dashboard',
|
|
});
|
|
|
|
// Available tools - customize this based on your needs
|
|
const tools = ref([
|
|
{
|
|
id: 1,
|
|
name: 'File Manager',
|
|
description: 'Upload, organize, and manage your files',
|
|
icon: 'mdi-folder-multiple',
|
|
color: 'blue',
|
|
status: 'active',
|
|
featured: true,
|
|
link: '/dashboard/files',
|
|
},
|
|
{
|
|
id: 2,
|
|
name: 'User Management',
|
|
description: 'Manage users and permissions',
|
|
icon: 'mdi-account-group',
|
|
color: 'green',
|
|
status: 'active',
|
|
featured: false,
|
|
link: '/dashboard/users',
|
|
},
|
|
{
|
|
id: 3,
|
|
name: 'Analytics',
|
|
description: 'View detailed analytics and reports',
|
|
icon: 'mdi-chart-bar',
|
|
color: 'purple',
|
|
status: 'active',
|
|
featured: false,
|
|
link: '/dashboard/analytics',
|
|
},
|
|
]);
|
|
|
|
// Coming soon tools
|
|
const comingSoonTools = ref([
|
|
{
|
|
id: 4,
|
|
name: 'API Manager',
|
|
description: 'Manage API keys and integrations',
|
|
icon: 'mdi-api',
|
|
},
|
|
{
|
|
id: 5,
|
|
name: 'Notifications',
|
|
description: 'Configure alerts and notifications',
|
|
icon: 'mdi-bell',
|
|
},
|
|
]);
|
|
</script>
|
|
```
|
|
|
|
## Phase 8: Health Check and Startup
|
|
|
|
### 8.1 Create Health Check API (server/api/health.get.ts)
|
|
|
|
```typescript
|
|
export default defineEventHandler(async (event) => {
|
|
const checks = {
|
|
server: 'ok',
|
|
database: 'unknown',
|
|
storage: 'unknown',
|
|
auth: 'unknown',
|
|
};
|
|
|
|
try {
|
|
// Test NocoDB connection
|
|
const nocodb = createNocoDBClient();
|
|
await nocodb.findAll('test', { limit: 1 });
|
|
checks.database = 'ok';
|
|
} catch (error) {
|
|
checks.database = 'error';
|
|
}
|
|
|
|
try {
|
|
// Test MinIO connection
|
|
const minio = createMinIOClient();
|
|
await minio.ensureBucket();
|
|
checks.storage = 'ok';
|
|
} catch (error) {
|
|
checks.storage = 'error';
|
|
}
|
|
|
|
try {
|
|
// Test Keycloak connection
|
|
const keycloak = createKeycloakClient();
|
|
// Simple connectivity test - you might want to implement a proper health check
|
|
checks.auth = 'ok';
|
|
} catch (error) {
|
|
checks.auth = 'error';
|
|
}
|
|
|
|
const allHealthy = Object.values(checks).every(status => status === 'ok');
|
|
|
|
return {
|
|
status: allHealthy ? 'healthy' : 'degraded',
|
|
timestamp: new Date().toISOString(),
|
|
checks,
|
|
};
|
|
});
|
|
```
|
|
|
|
### 8.2 Create Startup Plugin (plugins/01.auth-check.client.ts)
|
|
|
|
```typescript
|
|
export default defineNuxtPlugin(async () => {
|
|
const { checkAuth } = useAuth();
|
|
|
|
// Check authentication status on app startup
|
|
await checkAuth();
|
|
});
|
|
```
|
|
|
|
## Phase 9: TypeScript Types
|
|
|
|
### 9.1 Create Type Definitions (utils/types.ts)
|
|
|
|
```typescript
|
|
export interface User {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
groups?: string[];
|
|
tier?: string;
|
|
}
|
|
|
|
export interface AuthState {
|
|
authenticated: boolean;
|
|
user: User | null;
|
|
groups: string[];
|
|
}
|
|
|
|
export interface ApiResponse<T = any> {
|
|
success: boolean;
|
|
data?: T;
|
|
error?: string;
|
|
message?: string;
|
|
}
|
|
|
|
export interface FileUpload {
|
|
fieldName: string;
|
|
fileName: string;
|
|
originalName: string;
|
|
size: number;
|
|
contentType: string;
|
|
}
|
|
|
|
export interface DatabaseRecord {
|
|
id: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
[key: string]: any;
|
|
}
|
|
|
|
export interface HealthCheck {
|
|
status: 'healthy' | 'degraded' | 'unhealthy';
|
|
timestamp: string;
|
|
checks: {
|
|
server: string;
|
|
database: string;
|
|
storage: string;
|
|
auth: string;
|
|
};
|
|
}
|
|
```
|
|
|
|
## Phase 10: Final Setup and Testing
|
|
|
|
### 10.1 Create Main App File (app.vue)
|
|
|
|
```vue
|
|
<template>
|
|
<NuxtLayout>
|
|
<NuxtPage />
|
|
</NuxtLayout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
// Global app setup
|
|
useHead({
|
|
titleTemplate: (titleChunk) => {
|
|
return titleChunk ? `${titleChunk} • MonacoUSA Portal` : 'MonacoUSA Portal';
|
|
},
|
|
});
|
|
</script>
|
|
```
|
|
|
|
### 10.2 Create Index Page (pages/index.vue)
|
|
|
|
```vue
|
|
<template>
|
|
<div>
|
|
<!-- Redirect to dashboard if authenticated, otherwise to login -->
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
const { authenticated } = useAuth();
|
|
|
|
// Redirect based on authentication status
|
|
await navigateTo(authenticated.value ? '/dashboard' : '/login');
|
|
</script>
|
|
```
|
|
|
|
### 10.3 Create README.md
|
|
|
|
```markdown
|
|
# MonacoUSA Portal
|
|
|
|
A modern, responsive portal built with Nuxt 3, Vuetify, and Keycloak authentication.
|
|
|
|
## Features
|
|
|
|
- 🔐 **Keycloak Authentication** - Secure OAuth2/OIDC authentication
|
|
- 📱 **Mobile Responsive** - Works perfectly on all devices
|
|
- 🚀 **PWA Support** - Installable progressive web app
|
|
- 🎨 **Modern UI** - Beautiful Vuetify 3 interface with MonacoUSA branding
|
|
- 📁 **File Storage** - MinIO S3-compatible file storage
|
|
- 🗄️ **Database** - NocoDB for flexible data management
|
|
- 🔧 **Modular** - Easy to extend with new tools and features
|
|
|
|
## Tech Stack
|
|
|
|
- **Framework**: Nuxt 3 with Vue 3
|
|
- **UI Library**: Vuetify 3
|
|
- **Authentication**: Keycloak (OAuth2/OIDC)
|
|
- **Database**: NocoDB
|
|
- **File Storage**: MinIO (S3-compatible)
|
|
- **PWA**: Vite PWA plugin
|
|
- **TypeScript**: Full TypeScript support
|
|
|
|
## Quick Start
|
|
|
|
1. **Clone and Install**
|
|
```bash
|
|
git clone <repository-url>
|
|
cd monacousa-portal
|
|
npm install
|
|
```
|
|
|
|
2. **Environment Setup**
|
|
```bash
|
|
cp .env.example .env
|
|
# Edit .env with your configuration
|
|
```
|
|
|
|
3. **Development**
|
|
```bash
|
|
npm run dev
|
|
```
|
|
|
|
4. **Production Build**
|
|
```bash
|
|
npm run build
|
|
npm run preview
|
|
```
|
|
|
|
## Configuration
|
|
|
|
### Environment Variables
|
|
|
|
See `.env.example` for all required environment variables:
|
|
|
|
- **Keycloak**: Authentication server configuration
|
|
- **NocoDB**: Database connection settings
|
|
- **MinIO**: File storage configuration
|
|
- **Security**: Encryption keys and session secrets
|
|
|
|
### Keycloak Setup
|
|
|
|
1. Create a new client in your Keycloak realm
|
|
2. Set client type to "Confidential"
|
|
3. Configure redirect URIs:
|
|
- `https://monacousa.org/auth/callback`
|
|
- `http://localhost:3000/auth/callback` (development)
|
|
4. Enable "Standard Flow" authentication
|
|
5. Set up user attributes and groups as needed
|
|
|
|
### NocoDB Setup
|
|
|
|
1. Set up your NocoDB instance
|
|
2. Create a new base/project
|
|
3. Generate an API token
|
|
4. Configure tables as needed for your tools
|
|
|
|
### MinIO Setup
|
|
|
|
1. Set up MinIO server
|
|
2. Create access keys
|
|
3. Configure bucket policies
|
|
4. Set CORS policies for web access
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
monacousa-portal/
|
|
├── components/ # Vue components
|
|
├── composables/ # Vue composables
|
|
├── layouts/ # Nuxt layouts
|
|
├── middleware/ # Route middleware
|
|
├── pages/ # Application pages
|
|
│ ├── auth/ # Authentication pages
|
|
│ └── dashboard/ # Dashboard pages
|
|
├── plugins/ # Nuxt plugins
|
|
├── public/ # Static assets
|
|
├── server/ # Server-side code
|
|
│ ├── api/ # API routes
|
|
│ ├── utils/ # Server utilities
|
|
│ └── plugins/ # Server plugins
|
|
├── utils/ # Shared utilities
|
|
└── docs/ # Documentation
|
|
```
|
|
|
|
## Development
|
|
|
|
### Adding New Tools
|
|
|
|
1. Create a new page in `pages/dashboard/`
|
|
2. Add navigation item to dashboard layout
|
|
3. Implement API routes in `server/api/` if needed
|
|
4. Add database tables in NocoDB if required
|
|
|
|
### API Usage
|
|
|
|
The portal provides RESTful APIs for data operations:
|
|
|
|
```typescript
|
|
// Get all records from a table
|
|
const data = await $fetch('/api/data/users');
|
|
|
|
// Get single record
|
|
const user = await $fetch('/api/data/users/123');
|
|
|
|
// Create new record
|
|
const newUser = await $fetch('/api/data/users', {
|
|
method: 'POST',
|
|
body: { name: 'John Doe', email: 'john@example.com' }
|
|
});
|
|
|
|
// Update record
|
|
const updatedUser = await $fetch('/api/data/users/123', {
|
|
method: 'PATCH',
|
|
body: { name: 'Jane Doe' }
|
|
});
|
|
|
|
// Delete record
|
|
await $fetch('/api/data/users/123', { method: 'DELETE' });
|
|
```
|
|
|
|
### File Upload
|
|
|
|
```typescript
|
|
// Upload files
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const result = await $fetch('/api/files/upload', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
```
|
|
|
|
## Deployment
|
|
|
|
### Docker Deployment
|
|
|
|
```dockerfile
|
|
FROM node:18-alpine
|
|
|
|
WORKDIR /app
|
|
COPY package*.json ./
|
|
RUN npm ci --only=production
|
|
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
EXPOSE 3000
|
|
CMD ["npm", "run", "preview"]
|
|
```
|
|
|
|
### Environment Variables for Production
|
|
|
|
Ensure all environment variables are properly set:
|
|
- Use strong encryption keys
|
|
- Configure proper domain names
|
|
- Set up SSL certificates
|
|
- Configure firewall rules
|
|
|
|
## Health Checks
|
|
|
|
The portal includes health check endpoints:
|
|
|
|
- `GET /api/health` - Overall system health
|
|
- Check database connectivity
|
|
- Check file storage connectivity
|
|
- Check authentication service
|
|
|
|
## Security
|
|
|
|
- All sessions are encrypted
|
|
- HTTPS required in production
|
|
- CSRF protection enabled
|
|
- Secure cookie settings
|
|
- Input validation on all APIs
|
|
|
|
## Support
|
|
|
|
For issues and questions:
|
|
1. Check the documentation
|
|
2. Review environment configuration
|
|
3. Check health endpoints
|
|
4. Review server logs
|
|
|
|
## License
|
|
|
|
[Your License Here]
|
|
```
|
|
|
|
### 10.4 Create TypeScript Configuration (tsconfig.json)
|
|
|
|
```json
|
|
{
|
|
"extends": "./.nuxt/tsconfig.json"
|
|
}
|
|
```
|
|
|
|
### 10.5 Create Git Ignore (.gitignore)
|
|
|
|
```gitignore
|
|
# Nuxt dev/build outputs
|
|
.output
|
|
.nuxt
|
|
.nitro
|
|
.cache
|
|
dist
|
|
|
|
# Node dependencies
|
|
node_modules
|
|
|
|
# Logs
|
|
*.log*
|
|
|
|
# Misc
|
|
.DS_Store
|
|
.fleet
|
|
.idea
|
|
|
|
# Local env files
|
|
.env
|
|
.env.*
|
|
!.env.example
|
|
|
|
# Editor directories and files
|
|
.vscode/*
|
|
!.vscode/extensions.json
|
|
.idea
|
|
*.suo
|
|
*.ntvs*
|
|
*.njsproj
|
|
*.sln
|
|
*.sw?
|
|
```
|
|
|
|
## Phase 11: Testing and Verification
|
|
|
|
### 11.1 Development Testing
|
|
|
|
After setting up the project, test the following:
|
|
|
|
1. **Authentication Flow**
|
|
```bash
|
|
npm run dev
|
|
# Visit http://localhost:3000
|
|
# Test login/logout flow
|
|
```
|
|
|
|
2. **API Endpoints**
|
|
```bash
|
|
# Test health check
|
|
curl http://localhost:3000/api/health
|
|
|
|
# Test authentication session
|
|
curl http://localhost:3000/api/auth/session
|
|
```
|
|
|
|
3. **File Upload**
|
|
- Test file upload functionality
|
|
- Verify MinIO storage
|
|
- Check file download
|
|
|
|
4. **Database Operations**
|
|
- Test CRUD operations
|
|
- Verify NocoDB integration
|
|
- Check data persistence
|
|
|
|
### 11.2 Production Checklist
|
|
|
|
Before deploying to production:
|
|
|
|
- [ ] All environment variables configured
|
|
- [ ] SSL certificates installed
|
|
- [ ] Keycloak client properly configured
|
|
- [ ] NocoDB accessible and secured
|
|
- [ ] MinIO bucket policies configured
|
|
- [ ] Health checks passing
|
|
- [ ] PWA manifest and icons in place
|
|
- [ ] Error handling tested
|
|
- [ ] Mobile responsiveness verified
|
|
|
|
## Phase 12: Deployment Guide
|
|
|
|
### 12.1 Server Requirements
|
|
|
|
- Node.js 18+
|
|
- SSL certificate
|
|
- Reverse proxy (nginx/Apache)
|
|
- Firewall configuration
|
|
|
|
### 12.2 Environment Setup
|
|
|
|
1. **Production Environment Variables**
|
|
```bash
|
|
# Copy and configure
|
|
cp .env.example .env.production
|
|
```
|
|
|
|
2. **Build Application**
|
|
```bash
|
|
npm run build
|
|
```
|
|
|
|
3. **Start Production Server**
|
|
```bash
|
|
npm run preview
|
|
# or use PM2 for process management
|
|
pm2 start ecosystem.config.js
|
|
```
|
|
|
|
### 12.3 Nginx Configuration Example
|
|
|
|
```nginx
|
|
server {
|
|
listen 443 ssl http2;
|
|
server_name monacousa.org;
|
|
|
|
ssl_certificate /path/to/certificate.crt;
|
|
ssl_certificate_key /path/to/private.key;
|
|
|
|
location / {
|
|
proxy_pass http://localhost:3000;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection 'upgrade';
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_cache_bypass $http_upgrade;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Conclusion
|
|
|
|
This implementation guide provides a complete foundation for the MonacoUSA Portal. The portal includes:
|
|
|
|
✅ **Complete Authentication System** with Keycloak integration
|
|
✅ **Responsive Dashboard** that works on all devices
|
|
✅ **File Storage System** with MinIO integration
|
|
✅ **Database Integration** with NocoDB
|
|
✅ **PWA Support** for mobile installation
|
|
✅ **Modern UI** with Vuetify and MonacoUSA branding
|
|
✅ **Modular Architecture** for easy extension
|
|
✅ **Production-Ready** configuration and deployment guide
|
|
|
|
The foundation is ready for you to build your custom tools and features on top of this solid, proven architecture.
|