Docs Cleanup
This commit is contained in:
@@ -1,36 +0,0 @@
|
||||
FROM node:18-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build arguments for environment variables
|
||||
ARG VITE_AUTH0_DOMAIN
|
||||
ARG VITE_AUTH0_CLIENT_ID
|
||||
ARG VITE_TENANTS_API_URL
|
||||
|
||||
# Set environment variables for build
|
||||
ENV VITE_AUTH0_DOMAIN=${VITE_AUTH0_DOMAIN}
|
||||
ENV VITE_AUTH0_CLIENT_ID=${VITE_AUTH0_CLIENT_ID}
|
||||
ENV VITE_TENANTS_API_URL=${VITE_TENANTS_API_URL}
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built app to nginx
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MotoVaultPro - Vehicle Management Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,27 +0,0 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Single HTTP server for internal proxying (edge TLS handled by nginx-proxy)
|
||||
server {
|
||||
listen 3000;
|
||||
server_name localhost motovaultpro.com;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Handle React Router (SPA)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
server {
|
||||
listen 3000;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Handle React Router (SPA)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "mvp-platform-landing",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"@auth0/auth0-react": "^2.2.3",
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.0.6"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import HomePage from './components/HomePage'
|
||||
import TenantSignup from './components/TenantSignup'
|
||||
import CallbackHandler from './components/CallbackHandler'
|
||||
|
||||
function App() {
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/signup/:tenantId" element={<TenantSignup />} />
|
||||
<Route path="/callback" element={<CallbackHandler />} />
|
||||
</Routes>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,22 +0,0 @@
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
const CallbackHandler: React.FC = () => {
|
||||
useEffect(() => {
|
||||
// This component is no longer needed since we removed Auth0 from landing page
|
||||
// Redirect to main app
|
||||
window.location.href = 'https://admin.motovaultpro.com'
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'Arial, sans-serif'
|
||||
}}>
|
||||
<h2>Redirecting...</h2>
|
||||
<p>Please wait while we redirect you to MotoVaultPro.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CallbackHandler
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const handleLogin = () => {
|
||||
// Redirect directly to admin tenant for login
|
||||
window.location.href = 'https://admin.motovaultpro.com'
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', fontFamily: 'Arial, sans-serif' }}>
|
||||
<header style={{ textAlign: 'center', marginBottom: '3rem' }}>
|
||||
<h1>MotoVaultPro</h1>
|
||||
<p>The complete vehicle management platform for automotive professionals</p>
|
||||
</header>
|
||||
|
||||
<main style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||
<section style={{ marginBottom: '3rem' }}>
|
||||
<h2>Features</h2>
|
||||
<ul>
|
||||
<li>Vehicle inventory management</li>
|
||||
<li>Maintenance tracking and scheduling</li>
|
||||
<li>Fuel log analytics</li>
|
||||
<li>Service station locator</li>
|
||||
<li>Multi-tenant architecture for teams</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section style={{ textAlign: 'center' }}>
|
||||
<h2>Get Started</h2>
|
||||
<p>Already have an account?</p>
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
style={{
|
||||
padding: '1rem 2rem',
|
||||
fontSize: '1.1rem',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Access Your Dashboard
|
||||
</button>
|
||||
|
||||
<p style={{ marginTop: '2rem' }}>
|
||||
Need to join a team? Contact your tenant administrator for an invitation.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
@@ -1,109 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useAuth0 } from '@auth0/auth0-react'
|
||||
import axios from 'axios'
|
||||
|
||||
interface TenantInfo {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
}
|
||||
|
||||
const TenantSignup: React.FC = () => {
|
||||
const { tenantId } = useParams<{ tenantId: string }>()
|
||||
const { loginWithRedirect } = useAuth0()
|
||||
const [tenant, setTenant] = useState<TenantInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTenant = async () => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${import.meta.env.VITE_TENANTS_API_URL}/api/v1/tenants/${tenantId}`
|
||||
)
|
||||
setTenant(response.data)
|
||||
} catch (err) {
|
||||
setError('Tenant not found or not accepting signups')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (tenantId) {
|
||||
fetchTenant()
|
||||
}
|
||||
}, [tenantId])
|
||||
|
||||
const handleSignup = async () => {
|
||||
await loginWithRedirect({
|
||||
authorizationParams: {
|
||||
screen_hint: 'signup',
|
||||
redirect_uri: `${window.location.origin}/callback`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: '2rem' }}>Loading...</div>
|
||||
}
|
||||
|
||||
if (error || !tenant) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<h2>Tenant Not Found</h2>
|
||||
<p>{error}</p>
|
||||
<a href="/">Return to Homepage</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', maxWidth: '600px', margin: '0 auto', fontFamily: 'Arial, sans-serif' }}>
|
||||
<header style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
||||
<h1>Join {tenant.name}</h1>
|
||||
<p>Create your account to get started</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '2rem',
|
||||
backgroundColor: '#f9f9f9'
|
||||
}}>
|
||||
<h3>What happens next?</h3>
|
||||
<ol>
|
||||
<li>Create your account with Auth0</li>
|
||||
<li>Your signup request will be sent to the tenant administrator</li>
|
||||
<li>Once approved, you'll receive access to {tenant.name}</li>
|
||||
<li>Login at <code>{tenant.id}.motovaultpro.com</code></li>
|
||||
</ol>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
|
||||
<button
|
||||
onClick={handleSignup}
|
||||
style={{
|
||||
padding: '1rem 2rem',
|
||||
fontSize: '1.1rem',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Create Account for {tenant.name}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
|
||||
<a href="/">← Back to Homepage</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TenantSignup
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
11
mvp-platform-services/landing/src/vite-env.d.ts
vendored
11
mvp-platform-services/landing/src/vite-env.d.ts
vendored
@@ -1,11 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_AUTH0_DOMAIN: string
|
||||
readonly VITE_AUTH0_CLIENT_ID: string
|
||||
readonly VITE_TENANTS_API_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 3000
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true
|
||||
}
|
||||
})
|
||||
@@ -1,333 +0,0 @@
|
||||
# Auth0 Multi-Tenant Configuration Guide
|
||||
|
||||
This document provides step-by-step instructions for configuring Auth0 for the multi-tenant MotoVaultPro platform.
|
||||
|
||||
## Overview
|
||||
|
||||
The multi-tenant architecture requires:
|
||||
- **Landing Page**: `motovaultpro.com` - Entry point with tenant selection
|
||||
- **Admin Tenant**: `admin.motovaultpro.com` - Admin access to all tenants
|
||||
- **Regular Tenants**: `{tenant-id}.motovaultpro.com` - Isolated tenant access
|
||||
- **Signup Workflow**: Tenant-specific signup with admin approval
|
||||
|
||||
## Auth0 Application Configuration
|
||||
|
||||
### 1. Application Settings
|
||||
|
||||
**Application Type**: Single Page Application (SPA)
|
||||
|
||||
**Allowed Callback URLs**:
|
||||
```
|
||||
# Development URLs
|
||||
http://localhost:3002/callback
|
||||
http://admin.motovaultpro.local/callback
|
||||
http://demo-tenant.motovaultpro.local/callback
|
||||
|
||||
# Production URLs
|
||||
https://motovaultpro.com/callback
|
||||
https://admin.motovaultpro.com/callback
|
||||
https://demo-tenant.motovaultpro.com/callback
|
||||
|
||||
# Add additional tenant subdomains as needed:
|
||||
https://{tenant-id}.motovaultpro.com/callback
|
||||
```
|
||||
|
||||
**Allowed Logout URLs**:
|
||||
```
|
||||
# Development
|
||||
http://localhost:3002
|
||||
http://admin.motovaultpro.local
|
||||
http://demo-tenant.motovaultpro.local
|
||||
|
||||
# Production
|
||||
https://motovaultpro.com
|
||||
https://admin.motovaultpro.com
|
||||
https://demo-tenant.motovaultpro.com
|
||||
https://{tenant-id}.motovaultpro.com
|
||||
```
|
||||
|
||||
**Allowed Web Origins**:
|
||||
```
|
||||
# Development
|
||||
http://localhost:3002
|
||||
http://admin.motovaultpro.local:3000
|
||||
http://demo-tenant.motovaultpro.local:3000
|
||||
|
||||
# Production
|
||||
https://motovaultpro.com
|
||||
https://admin.motovaultpro.com
|
||||
https://demo-tenant.motovaultpro.com
|
||||
https://{tenant-id}.motovaultpro.com
|
||||
```
|
||||
|
||||
### 2. JWT Configuration
|
||||
|
||||
**JWT Signature Algorithm**: RS256
|
||||
|
||||
**OIDC Conformant**: Enabled
|
||||
|
||||
### 3. Advanced Settings
|
||||
|
||||
**Grant Types**:
|
||||
- Authorization Code
|
||||
- Refresh Token
|
||||
- Implicit (for development only)
|
||||
|
||||
## Auth0 Rules Configuration
|
||||
|
||||
### Rule 1: Add Tenant Context to JWT
|
||||
|
||||
Create a new Rule in Auth0 Dashboard > Auth Pipeline > Rules:
|
||||
|
||||
```javascript
|
||||
function addTenantContext(user, context, callback) {
|
||||
const namespace = 'https://motovaultpro.com/';
|
||||
|
||||
// Extract tenant_id from user metadata (set during signup)
|
||||
let tenantId = user.user_metadata && user.user_metadata.tenant_id;
|
||||
|
||||
// For existing users without tenant metadata, default to admin
|
||||
if (!tenantId) {
|
||||
tenantId = 'admin';
|
||||
// Optionally update user metadata
|
||||
user.user_metadata = user.user_metadata || {};
|
||||
user.user_metadata.tenant_id = tenantId;
|
||||
}
|
||||
|
||||
// Check signup status for non-admin tenants
|
||||
const signupStatus = user.user_metadata && user.user_metadata.signup_status;
|
||||
|
||||
if (tenantId !== 'admin' && signupStatus !== 'approved') {
|
||||
// Block login for unapproved users
|
||||
return callback(new UnauthorizedError('Account pending approval'));
|
||||
}
|
||||
|
||||
// Add tenant context to tokens
|
||||
context.idToken[namespace + 'tenant_id'] = tenantId;
|
||||
context.accessToken[namespace + 'tenant_id'] = tenantId;
|
||||
context.idToken[namespace + 'signup_status'] = signupStatus || 'approved';
|
||||
|
||||
callback(null, user, context);
|
||||
}
|
||||
```
|
||||
|
||||
### Rule 2: Tenant-Specific User Metadata
|
||||
|
||||
```javascript
|
||||
function setTenantMetadata(user, context, callback) {
|
||||
const namespace = 'https://motovaultpro.com/';
|
||||
|
||||
// If this is a signup and connection is Username-Password-Authentication
|
||||
if (context.stats.loginsCount === 1 && context.connection === 'Username-Password-Authentication') {
|
||||
|
||||
// Extract tenant from redirect_uri or state parameter
|
||||
const redirectUri = context.request.query.redirect_uri || '';
|
||||
const tenantMatch = redirectUri.match(/([a-z0-9-]+)\.motovaultpro\.(com|local)/);
|
||||
|
||||
if (tenantMatch) {
|
||||
const tenantId = tenantMatch[1];
|
||||
|
||||
// Set initial user metadata
|
||||
user.user_metadata = user.user_metadata || {};
|
||||
user.user_metadata.tenant_id = tenantId;
|
||||
|
||||
// Set signup status (pending for regular tenants, approved for admin)
|
||||
user.user_metadata.signup_status = tenantId === 'admin' ? 'approved' : 'pending';
|
||||
|
||||
// Update user metadata in Auth0
|
||||
auth0.users.updateUserMetadata(user.user_id, user.user_metadata);
|
||||
}
|
||||
}
|
||||
|
||||
callback(null, user, context);
|
||||
}
|
||||
```
|
||||
|
||||
## Tenant Signup Flow Configuration
|
||||
|
||||
### 1. Signup URLs
|
||||
|
||||
**Tenant-Specific Signup**:
|
||||
```
|
||||
https://motovaultpro.com/signup/{tenant-id}
|
||||
```
|
||||
|
||||
**Process**:
|
||||
1. User visits tenant-specific signup URL
|
||||
2. Landing page validates tenant exists
|
||||
3. Redirects to Auth0 with tenant context
|
||||
4. Auth0 Rule sets tenant_id in user metadata
|
||||
5. User account created with status="pending"
|
||||
6. Tenant admin receives notification
|
||||
7. Admin approves/rejects via tenant management API
|
||||
|
||||
### 2. Auth0 Hosted Login Customization
|
||||
|
||||
Add custom CSS and JavaScript to Auth0 Universal Login to support tenant context:
|
||||
|
||||
**Custom CSS** (Dashboard > Universal Login > Advanced Options):
|
||||
```css
|
||||
.tenant-signup-info {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
```
|
||||
|
||||
**Custom JavaScript**:
|
||||
```javascript
|
||||
// Extract tenant from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const redirectUri = urlParams.get('redirect_uri') || '';
|
||||
const tenantMatch = redirectUri.match(/([a-z0-9-]+)\.motovaultpro\.(com|local)/);
|
||||
|
||||
if (tenantMatch && tenantMatch[1] !== 'admin') {
|
||||
const tenantName = tenantMatch[1].replace('-', ' ').toUpperCase();
|
||||
|
||||
// Add tenant information to signup form
|
||||
const container = document.querySelector('.auth0-lock-header');
|
||||
if (container) {
|
||||
const info = document.createElement('div');
|
||||
info.className = 'tenant-signup-info';
|
||||
info.innerHTML = `
|
||||
<strong>Signing up for: ${tenantName}</strong><br>
|
||||
<small>Your account will require admin approval before you can access the system.</small>
|
||||
`;
|
||||
container.appendChild(info);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## JWT Token Format
|
||||
|
||||
After successful authentication, JWT tokens will include:
|
||||
|
||||
**ID Token Claims**:
|
||||
```json
|
||||
{
|
||||
"sub": "auth0|user-123",
|
||||
"email": "user@example.com",
|
||||
"https://motovaultpro.com/tenant_id": "demo-tenant",
|
||||
"https://motovaultpro.com/signup_status": "approved",
|
||||
"iat": 1699123456,
|
||||
"exp": 1699127056
|
||||
}
|
||||
```
|
||||
|
||||
**Access Token Claims**:
|
||||
```json
|
||||
{
|
||||
"sub": "auth0|user-123",
|
||||
"https://motovaultpro.com/tenant_id": "demo-tenant",
|
||||
"scope": "openid profile email",
|
||||
"iat": 1699123456,
|
||||
"exp": 1699127056
|
||||
}
|
||||
```
|
||||
|
||||
## Backend JWT Validation
|
||||
|
||||
Services should validate JWT tokens and extract tenant context:
|
||||
|
||||
```typescript
|
||||
// Example JWT validation middleware
|
||||
import jwt from 'jsonwebtoken';
|
||||
import jwksClient from 'jwks-rsa';
|
||||
|
||||
const client = jwksClient({
|
||||
jwksUri: `https://${AUTH0_DOMAIN}/.well-known/jwks.json`
|
||||
});
|
||||
|
||||
function getKey(header: any, callback: any) {
|
||||
client.getSigningKey(header.kid, (err, key) => {
|
||||
if (err) return callback(err);
|
||||
const signingKey = key.getPublicKey();
|
||||
callback(null, signingKey);
|
||||
});
|
||||
}
|
||||
|
||||
export const validateJWT = (token: string): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.verify(token, getKey, {
|
||||
audience: process.env.AUTH0_AUDIENCE,
|
||||
issuer: `https://${process.env.AUTH0_DOMAIN}/`,
|
||||
algorithms: ['RS256']
|
||||
}, (err, decoded) => {
|
||||
if (err) return reject(err);
|
||||
resolve(decoded);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Extract tenant from validated JWT
|
||||
export const getTenantFromToken = (decodedToken: any): string => {
|
||||
return decodedToken['https://motovaultpro.com/tenant_id'] || 'admin';
|
||||
};
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Configure the following environment variables for each service:
|
||||
|
||||
**Platform Services**:
|
||||
```env
|
||||
AUTH0_DOMAIN=your-domain.auth0.com
|
||||
AUTH0_AUDIENCE=https://api.motovaultpro.com
|
||||
```
|
||||
|
||||
**Landing Page Service**:
|
||||
```env
|
||||
VITE_AUTH0_DOMAIN=your-domain.auth0.com
|
||||
VITE_AUTH0_CLIENT_ID=your-client-id
|
||||
VITE_TENANTS_API_URL=http://mvp-platform-tenants:8000
|
||||
```
|
||||
|
||||
**Admin/Tenant Services**:
|
||||
```env
|
||||
REACT_APP_AUTH0_DOMAIN=your-domain.auth0.com
|
||||
REACT_APP_AUTH0_CLIENT_ID=your-client-id
|
||||
REACT_APP_AUTH0_AUDIENCE=https://api.motovaultpro.com
|
||||
REACT_APP_TENANT_ID=admin # or specific tenant ID
|
||||
```
|
||||
|
||||
## Testing the Configuration
|
||||
|
||||
### 1. Test Admin Login
|
||||
```bash
|
||||
# Visit admin tenant
|
||||
open http://admin.motovaultpro.local
|
||||
|
||||
# Should redirect to Auth0, login, then return to admin app
|
||||
```
|
||||
|
||||
### 2. Test Tenant Signup
|
||||
```bash
|
||||
# Visit tenant signup
|
||||
open http://motovaultpro.local/signup/demo-tenant
|
||||
|
||||
# Complete signup, verify pending status
|
||||
curl -H "Authorization: Bearer admin-token" \
|
||||
http://localhost:8001/api/v1/signups
|
||||
```
|
||||
|
||||
### 3. Test Approval Workflow
|
||||
```bash
|
||||
# Approve signup
|
||||
curl -X PUT -H "Authorization: Bearer admin-token" \
|
||||
http://localhost:8001/api/v1/signups/1/approve
|
||||
|
||||
# User should now be able to login to tenant
|
||||
open http://demo-tenant.motovaultpro.local
|
||||
```
|
||||
|
||||
## Production Deployment Notes
|
||||
|
||||
1. **SSL Certificates**: Ensure wildcard SSL certificate for `*.motovaultpro.com`
|
||||
2. **DNS Configuration**: Set up wildcard DNS or individual A records per tenant
|
||||
3. **Auth0 Environment**: Use production Auth0 tenant with proper security settings
|
||||
4. **Rate Limiting**: Configure Auth0 rate limiting for signup endpoints
|
||||
5. **Monitoring**: Set up Auth0 logs monitoring for failed login attempts
|
||||
|
||||
This configuration provides a secure, scalable multi-tenant authentication system with proper tenant isolation and admin approval workflows.
|
||||
@@ -1,525 +0,0 @@
|
||||
"""
|
||||
MVP Platform Tenants Service - FastAPI Application
|
||||
Handles tenant management, signup approvals, and multi-tenant infrastructure.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends, Header
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import asyncpg
|
||||
import os
|
||||
import json
|
||||
import httpx
|
||||
from typing import Optional, List, Dict
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from jose import jwt, jwk
|
||||
from jose.exceptions import JWTError, ExpiredSignatureError
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="MVP Platform Tenants Service",
|
||||
description="Multi-tenant management and signup approval service",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Configure appropriately for production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Auth0 configuration
|
||||
AUTH0_DOMAIN = os.getenv("AUTH0_DOMAIN")
|
||||
AUTH0_AUDIENCE = os.getenv("AUTH0_AUDIENCE", "https://api.motovaultpro.com")
|
||||
|
||||
# Cache for JWKS keys (in production, use Redis)
|
||||
_jwks_cache = {}
|
||||
_jwks_cache_expiry = 0
|
||||
|
||||
# Database connection
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://platform_user:platform_pass@platform-postgres:5432/platform")
|
||||
|
||||
# Helper function to parse JSON settings
|
||||
def parse_json_field(value):
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return value or {}
|
||||
|
||||
# Models
|
||||
class TenantCreate(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
subdomain: str
|
||||
admin_user_id: Optional[str] = None
|
||||
settings: dict = {}
|
||||
|
||||
class TenantResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
subdomain: str
|
||||
status: str
|
||||
admin_user_id: Optional[str]
|
||||
settings: dict
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_db_row(cls, row):
|
||||
data = dict(row)
|
||||
data['settings'] = parse_json_field(data.get('settings'))
|
||||
return cls(**data)
|
||||
|
||||
class SignupRequest(BaseModel):
|
||||
user_email: str
|
||||
user_auth0_id: Optional[str] = None
|
||||
|
||||
class SignupResponse(BaseModel):
|
||||
id: int
|
||||
tenant_id: str
|
||||
user_email: str
|
||||
user_auth0_id: Optional[str]
|
||||
status: str
|
||||
requested_at: datetime
|
||||
approved_by: Optional[str] = None
|
||||
approved_at: Optional[datetime] = None
|
||||
rejected_at: Optional[datetime] = None
|
||||
rejection_reason: Optional[str] = None
|
||||
|
||||
class SignupApproval(BaseModel):
|
||||
reason: Optional[str] = None
|
||||
|
||||
# JWT Authentication functions
|
||||
async def get_jwks() -> Dict:
|
||||
"""Fetch JWKS from Auth0 with caching"""
|
||||
global _jwks_cache, _jwks_cache_expiry
|
||||
import time
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
# Return cached JWKS if not expired (cache for 1 hour)
|
||||
if _jwks_cache and current_time < _jwks_cache_expiry:
|
||||
return _jwks_cache
|
||||
|
||||
if not AUTH0_DOMAIN:
|
||||
raise HTTPException(status_code=500, detail="Auth0 configuration missing")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"https://{AUTH0_DOMAIN}/.well-known/jwks.json")
|
||||
response.raise_for_status()
|
||||
jwks = response.json()
|
||||
|
||||
# Cache the JWKS for 1 hour
|
||||
_jwks_cache = jwks
|
||||
_jwks_cache_expiry = current_time + 3600
|
||||
|
||||
return jwks
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch JWKS: {str(e)}")
|
||||
|
||||
async def get_signing_key(kid: str) -> str:
|
||||
"""Get signing key for the given kid"""
|
||||
jwks = await get_jwks()
|
||||
|
||||
for key in jwks.get("keys", []):
|
||||
if key.get("kid") == kid:
|
||||
return jwk.construct(key).key
|
||||
|
||||
raise HTTPException(status_code=401, detail="Unable to find appropriate key")
|
||||
|
||||
async def verify_jwt(token: str) -> Dict:
|
||||
"""Verify and decode JWT token"""
|
||||
if not AUTH0_DOMAIN or not AUTH0_AUDIENCE:
|
||||
raise HTTPException(status_code=500, detail="Auth0 configuration missing")
|
||||
|
||||
try:
|
||||
# Get the kid from token header
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
kid = unverified_header.get("kid")
|
||||
|
||||
if not kid:
|
||||
raise HTTPException(status_code=401, detail="Token header missing kid")
|
||||
|
||||
# Get the signing key
|
||||
signing_key = await get_signing_key(kid)
|
||||
|
||||
# Verify and decode the token
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key,
|
||||
algorithms=["RS256"],
|
||||
audience=AUTH0_AUDIENCE,
|
||||
issuer=f"https://{AUTH0_DOMAIN}/"
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
except ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token has expired")
|
||||
except JWTError as e:
|
||||
raise HTTPException(status_code=401, detail=f"Invalid token: {str(e)}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=401, detail=f"Token validation failed: {str(e)}")
|
||||
|
||||
# Mock authentication for development/testing
|
||||
async def mock_auth_user(authorization: str) -> Dict:
|
||||
"""Mock authentication for testing purposes"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Authorization header required")
|
||||
|
||||
token = authorization.split(" ")[1]
|
||||
|
||||
if token == "admin-token":
|
||||
return {
|
||||
"sub": "admin-user",
|
||||
"email": "admin@motovaultpro.com",
|
||||
"https://motovaultpro.com/tenant_id": "admin",
|
||||
"https://motovaultpro.com/signup_status": "approved"
|
||||
}
|
||||
elif token.startswith("tenant-"):
|
||||
tenant_id = token.replace("tenant-", "", 1).replace("-token", "")
|
||||
return {
|
||||
"sub": f"{tenant_id}-admin",
|
||||
"email": f"admin@{tenant_id}.com",
|
||||
"https://motovaultpro.com/tenant_id": tenant_id,
|
||||
"https://motovaultpro.com/signup_status": "approved"
|
||||
}
|
||||
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
async def get_current_user(authorization: str = Header(None)):
|
||||
"""Extract and validate JWT from Authorization header"""
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Authorization header required")
|
||||
|
||||
try:
|
||||
scheme, token = authorization.split(" ", 1)
|
||||
if scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=401, detail="Invalid authentication scheme")
|
||||
|
||||
# Try real JWT validation first, fallback to mock for development
|
||||
try:
|
||||
if AUTH0_DOMAIN and AUTH0_AUDIENCE:
|
||||
payload = await verify_jwt(token)
|
||||
else:
|
||||
payload = await mock_auth_user(authorization)
|
||||
except HTTPException:
|
||||
# Fallback to mock authentication for development
|
||||
payload = await mock_auth_user(authorization)
|
||||
|
||||
# Extract tenant info from JWT claims
|
||||
tenant_id = payload.get("https://motovaultpro.com/tenant_id", "admin")
|
||||
user_id = payload.get("sub", "")
|
||||
email = payload.get("email", "")
|
||||
|
||||
return {
|
||||
"sub": user_id,
|
||||
"tenant_id": tenant_id,
|
||||
"email": email,
|
||||
"payload": payload
|
||||
}
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid authorization header format")
|
||||
|
||||
async def get_admin_user(current_user: dict = Depends(get_current_user)):
|
||||
if current_user.get("tenant_id") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
async def get_tenant_admin(current_user: dict = Depends(get_current_user)):
|
||||
if not current_user.get("tenant_id"):
|
||||
raise HTTPException(status_code=401, detail="Tenant authentication required")
|
||||
return current_user
|
||||
|
||||
# Health check
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
await conn.execute("SELECT 1")
|
||||
await conn.close()
|
||||
return {
|
||||
"status": "healthy",
|
||||
"database": "connected",
|
||||
"service": "mvp-platform-tenants",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}")
|
||||
raise HTTPException(status_code=503, detail="Service unavailable")
|
||||
|
||||
# Tenant management endpoints
|
||||
@app.post("/api/v1/tenants", response_model=TenantResponse)
|
||||
async def create_tenant(
|
||||
tenant_data: TenantCreate,
|
||||
current_user: dict = Depends(get_admin_user)
|
||||
):
|
||||
"""Create new tenant (admin only)"""
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
# Check if tenant already exists
|
||||
existing = await conn.fetchrow(
|
||||
"SELECT id FROM tenants WHERE id = $1 OR subdomain = $2",
|
||||
tenant_data.id, tenant_data.subdomain
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Tenant ID or subdomain already exists")
|
||||
|
||||
# Insert new tenant
|
||||
result = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO tenants (id, name, subdomain, admin_user_id, settings)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *
|
||||
""",
|
||||
tenant_data.id,
|
||||
tenant_data.name,
|
||||
tenant_data.subdomain,
|
||||
tenant_data.admin_user_id,
|
||||
json.dumps(tenant_data.settings)
|
||||
)
|
||||
|
||||
return TenantResponse.from_db_row(result)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@app.get("/api/v1/tenants", response_model=List[TenantResponse])
|
||||
async def list_tenants(current_user: dict = Depends(get_admin_user)):
|
||||
"""List all tenants (admin only)"""
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
results = await conn.fetch("SELECT * FROM tenants ORDER BY created_at DESC")
|
||||
return [TenantResponse.from_db_row(row) for row in results]
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@app.get("/api/v1/tenants/{tenant_id}", response_model=TenantResponse)
|
||||
async def get_tenant(tenant_id: str):
|
||||
"""Get tenant details (public endpoint for validation)"""
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
result = await conn.fetchrow("SELECT * FROM tenants WHERE id = $1", tenant_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
return TenantResponse.from_db_row(result)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@app.put("/api/v1/tenants/{tenant_id}", response_model=TenantResponse)
|
||||
async def update_tenant(
|
||||
tenant_id: str,
|
||||
tenant_data: TenantCreate,
|
||||
current_user: dict = Depends(get_admin_user)
|
||||
):
|
||||
"""Update tenant settings (admin only)"""
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
result = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE tenants
|
||||
SET name = $2, admin_user_id = $3, settings = $4, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
""",
|
||||
tenant_id,
|
||||
tenant_data.name,
|
||||
tenant_data.admin_user_id,
|
||||
json.dumps(tenant_data.settings)
|
||||
)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
return TenantResponse.from_db_row(result)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
# Signup management endpoints
|
||||
@app.post("/api/v1/tenants/{tenant_id}/signups", response_model=SignupResponse)
|
||||
async def request_signup(tenant_id: str, signup_data: SignupRequest):
|
||||
"""Request signup approval for a tenant (public endpoint)"""
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
# Verify tenant exists and accepts signups
|
||||
tenant = await conn.fetchrow(
|
||||
"SELECT id, status FROM tenants WHERE id = $1", tenant_id
|
||||
)
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
if tenant['status'] != 'active':
|
||||
raise HTTPException(status_code=400, detail="Tenant not accepting signups")
|
||||
|
||||
# Check for existing signup
|
||||
existing = await conn.fetchrow(
|
||||
"SELECT id FROM tenant_signups WHERE tenant_id = $1 AND user_email = $2",
|
||||
tenant_id, signup_data.user_email
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Signup request already exists")
|
||||
|
||||
# Create signup request
|
||||
result = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO tenant_signups (tenant_id, user_email, user_auth0_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
""",
|
||||
tenant_id,
|
||||
signup_data.user_email,
|
||||
signup_data.user_auth0_id
|
||||
)
|
||||
|
||||
logger.info(f"New signup request: {signup_data.user_email} for tenant {tenant_id}")
|
||||
return SignupResponse(**dict(result))
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@app.get("/api/v1/tenants/{tenant_id}/signups", response_model=List[SignupResponse])
|
||||
async def get_tenant_signups(
|
||||
tenant_id: str,
|
||||
status: Optional[str] = "pending",
|
||||
current_user: dict = Depends(get_tenant_admin)
|
||||
):
|
||||
"""List signups for a tenant (tenant admin only)"""
|
||||
# Verify user has access to this tenant
|
||||
if current_user.get("tenant_id") != tenant_id and current_user.get("tenant_id") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
query = "SELECT * FROM tenant_signups WHERE tenant_id = $1"
|
||||
params = [tenant_id]
|
||||
|
||||
if status:
|
||||
query += " AND status = $2"
|
||||
params.append(status)
|
||||
|
||||
query += " ORDER BY requested_at DESC"
|
||||
|
||||
results = await conn.fetch(query, *params)
|
||||
return [SignupResponse(**dict(row)) for row in results]
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@app.get("/api/v1/signups", response_model=List[SignupResponse])
|
||||
async def get_all_signups(
|
||||
status: Optional[str] = "pending",
|
||||
current_user: dict = Depends(get_admin_user)
|
||||
):
|
||||
"""List all signups across all tenants (admin only)"""
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
query = "SELECT * FROM tenant_signups"
|
||||
params = []
|
||||
|
||||
if status:
|
||||
query += " WHERE status = $1"
|
||||
params.append(status)
|
||||
|
||||
query += " ORDER BY requested_at DESC"
|
||||
|
||||
results = await conn.fetch(query, *params)
|
||||
return [SignupResponse(**dict(row)) for row in results]
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@app.put("/api/v1/signups/{signup_id}/approve")
|
||||
async def approve_signup(
|
||||
signup_id: int,
|
||||
current_user: dict = Depends(get_tenant_admin)
|
||||
):
|
||||
"""Approve a signup request (tenant admin only)"""
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
# Get signup details to verify tenant access
|
||||
signup = await conn.fetchrow(
|
||||
"SELECT * FROM tenant_signups WHERE id = $1", signup_id
|
||||
)
|
||||
if not signup:
|
||||
raise HTTPException(status_code=404, detail="Signup not found")
|
||||
|
||||
# Verify user has access to approve this signup
|
||||
if (current_user.get("tenant_id") != signup['tenant_id'] and
|
||||
current_user.get("tenant_id") != "admin"):
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
result = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE tenant_signups
|
||||
SET status = 'approved', approved_by = $2, approved_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING *
|
||||
""",
|
||||
signup_id,
|
||||
current_user['sub']
|
||||
)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Signup not found or already processed")
|
||||
|
||||
# TODO: Update Auth0 user metadata to set signup_status = 'approved'
|
||||
logger.info(f"Approved signup {signup_id} for user {result['user_email']} by {current_user['sub']}")
|
||||
|
||||
return {"status": "approved", "signup_id": signup_id}
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@app.put("/api/v1/signups/{signup_id}/reject")
|
||||
async def reject_signup(
|
||||
signup_id: int,
|
||||
approval_data: SignupApproval,
|
||||
current_user: dict = Depends(get_tenant_admin)
|
||||
):
|
||||
"""Reject a signup request (tenant admin only)"""
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
# Get signup details to verify tenant access
|
||||
signup = await conn.fetchrow(
|
||||
"SELECT * FROM tenant_signups WHERE id = $1", signup_id
|
||||
)
|
||||
if not signup:
|
||||
raise HTTPException(status_code=404, detail="Signup not found")
|
||||
|
||||
# Verify user has access to reject this signup
|
||||
if (current_user.get("tenant_id") != signup['tenant_id'] and
|
||||
current_user.get("tenant_id") != "admin"):
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
reason = approval_data.reason or "No reason provided"
|
||||
|
||||
result = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE tenant_signups
|
||||
SET status = 'rejected', approved_by = $2, rejected_at = CURRENT_TIMESTAMP, rejection_reason = $3
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING *
|
||||
""",
|
||||
signup_id,
|
||||
current_user['sub'],
|
||||
reason
|
||||
)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Signup not found or already processed")
|
||||
|
||||
logger.info(f"Rejected signup {signup_id} for user {result['user_email']}: {reason}")
|
||||
|
||||
return {"status": "rejected", "signup_id": signup_id, "reason": reason}
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -1,21 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY api/ .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
@@ -1,7 +0,0 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
asyncpg==0.29.0
|
||||
pydantic==2.5.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
python-multipart==0.0.6
|
||||
httpx==0.25.2
|
||||
@@ -1,41 +0,0 @@
|
||||
-- Tenant registry schema for MVP Platform Tenants Service
|
||||
-- Creates core tenant management tables
|
||||
|
||||
-- Tenant registry
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id VARCHAR(100) PRIMARY KEY, -- 'admin', 'acme-corp', etc.
|
||||
name VARCHAR(255) NOT NULL, -- Display name
|
||||
subdomain VARCHAR(100) UNIQUE NOT NULL, -- Same as id for simplicity
|
||||
status VARCHAR(50) DEFAULT 'active', -- active, pending, suspended
|
||||
admin_user_id VARCHAR(255), -- Auth0 user ID of tenant admin
|
||||
settings JSONB DEFAULT '{}', -- Tenant-specific configuration
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenants_admin_user ON tenants(admin_user_id);
|
||||
|
||||
-- Tenant signup approval workflow
|
||||
CREATE TABLE IF NOT EXISTS tenant_signups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tenant_id VARCHAR(100) REFERENCES tenants(id),
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
user_auth0_id VARCHAR(255), -- Auth0 user ID after signup
|
||||
status VARCHAR(50) DEFAULT 'pending', -- pending, approved, rejected
|
||||
requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
approved_by VARCHAR(255), -- Auth0 ID of approving admin
|
||||
approved_at TIMESTAMP,
|
||||
rejected_at TIMESTAMP,
|
||||
rejection_reason TEXT
|
||||
);
|
||||
|
||||
-- Create indexes for signup queries
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_signups_tenant_status ON tenant_signups(tenant_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_signups_user_email ON tenant_signups(user_email);
|
||||
|
||||
-- Initial admin tenant data
|
||||
INSERT INTO tenants (id, name, subdomain, status, admin_user_id)
|
||||
VALUES ('admin', 'Admin Tenant', 'admin', 'active', NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
Reference in New Issue
Block a user