diff --git a/nocodb-map-viewer/.gitignore b/map/.gitignore
similarity index 100%
rename from nocodb-map-viewer/.gitignore
rename to map/.gitignore
diff --git a/nocodb-map-viewer/README.md b/map/README.md
similarity index 100%
rename from nocodb-map-viewer/README.md
rename to map/README.md
diff --git a/nocodb-map-viewer/app/Dockerfile b/map/app/Dockerfile
similarity index 100%
rename from nocodb-map-viewer/app/Dockerfile
rename to map/app/Dockerfile
diff --git a/nocodb-map-viewer/app/package-lock.json b/map/app/package-lock.json
similarity index 94%
rename from nocodb-map-viewer/app/package-lock.json
rename to map/app/package-lock.json
index 65aa5e5..4e2f404 100644
--- a/nocodb-map-viewer/app/package-lock.json
+++ b/map/app/package-lock.json
@@ -10,10 +10,12 @@
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
+ "cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.4",
+ "express-session": "^1.18.1",
"helmet": "^7.1.0",
"winston": "^3.11.0"
},
@@ -331,6 +333,28 @@
"node": ">= 0.6"
}
},
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-parser/node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -555,6 +579,40 @@
"express": ">= 4.11"
}
},
+ "node_modules/express-session": {
+ "version": "1.18.1",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz",
+ "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.7",
+ "debug": "2.6.9",
+ "depd": "~2.0.0",
+ "on-headers": "~1.0.2",
+ "parseurl": "~1.3.3",
+ "safe-buffer": "5.2.1",
+ "uid-safe": "~2.1.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/express-session/node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express-session/node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
"node_modules/fecha": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
@@ -1133,6 +1191,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/on-headers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
+ "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/one-time": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
@@ -1211,6 +1278,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/random-bytes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
+ "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1564,6 +1640,18 @@
"node": ">= 0.6"
}
},
+ "node_modules/uid-safe": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
+ "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
+ "license": "MIT",
+ "dependencies": {
+ "random-bytes": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
diff --git a/nocodb-map-viewer/app/package.json b/map/app/package.json
similarity index 91%
rename from nocodb-map-viewer/app/package.json
rename to map/app/package.json
index 5cc4a14..7ac5132 100644
--- a/nocodb-map-viewer/app/package.json
+++ b/map/app/package.json
@@ -18,12 +18,14 @@
"author": "",
"license": "MIT",
"dependencies": {
- "express": "^4.18.2",
"axios": "^1.6.0",
+ "cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
- "helmet": "^7.1.0",
+ "express": "^4.18.2",
"express-rate-limit": "^7.1.4",
+ "express-session": "^1.18.1",
+ "helmet": "^7.1.0",
"winston": "^3.11.0"
},
"devDependencies": {
diff --git a/nocodb-map-viewer/app/public/css/style.css b/map/app/public/css/style.css
similarity index 96%
rename from nocodb-map-viewer/app/public/css/style.css
rename to map/app/public/css/style.css
index 0e6c77b..99f4755 100644
--- a/nocodb-map-viewer/app/public/css/style.css
+++ b/map/app/public/css/style.css
@@ -66,6 +66,21 @@ body {
font-size: 14px;
}
+/* User info in header */
+.user-info {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ padding: 0 15px;
+ border-right: 1px solid rgba(255,255,255,0.2);
+ margin-right: 15px;
+}
+
+.user-email {
+ font-size: 14px;
+ color: rgba(255,255,255,0.9);
+}
+
/* Map container */
#map-container {
flex: 1;
@@ -555,6 +570,11 @@ body {
display: none;
}
+ /* Hide user info on mobile to save space */
+ .user-info {
+ display: none;
+ }
+
.btn {
padding: 10px;
min-width: 40px;
diff --git a/nocodb-map-viewer/app/public/index.html b/map/app/public/index.html
similarity index 100%
rename from nocodb-map-viewer/app/public/index.html
rename to map/app/public/index.html
diff --git a/nocodb-map-viewer/app/public/js/map.js b/map/app/public/js/map.js
similarity index 94%
rename from nocodb-map-viewer/app/public/js/map.js
rename to map/app/public/js/map.js
index 7ed4912..7460317 100644
--- a/nocodb-map-viewer/app/public/js/map.js
+++ b/map/app/public/js/map.js
@@ -14,11 +14,13 @@ let markers = [];
let userLocationMarker = null;
let isAddingLocation = false;
let refreshInterval = null;
-let currentEditingLocation = null; // Add this line
+let currentEditingLocation = null;
+let currentUser = null; // Add current user state
// Initialize application when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
initializeMap();
+ checkAuthentication(); // Add authentication check
loadLocations();
setupEventListeners();
checkConfiguration();
@@ -222,6 +224,68 @@ function setupGeoFieldSync() {
});
}
+// Check authentication and display user info
+async function checkAuthentication() {
+ try {
+ const response = await fetch('/api/auth/check');
+ const data = await response.json();
+
+ if (data.authenticated && data.user) {
+ currentUser = data.user;
+ displayUserInfo();
+ }
+ } catch (error) {
+ console.error('Failed to check authentication:', error);
+ }
+}
+
+// Display user info in header
+function displayUserInfo() {
+ const headerActions = document.querySelector('.header-actions');
+
+ // Create user info element
+ const userInfo = document.createElement('div');
+ userInfo.className = 'user-info';
+ userInfo.innerHTML = `
+ ${escapeHtml(currentUser.email)}
+
+ `;
+
+ // Insert before the location count
+ const locationCount = document.getElementById('location-count');
+ headerActions.insertBefore(userInfo, locationCount);
+
+ // Add logout event listener
+ document.getElementById('logout-btn').addEventListener('click', handleLogout);
+}
+
+// Handle logout
+async function handleLogout() {
+ if (!confirm('Are you sure you want to logout?')) {
+ return;
+ }
+
+ try {
+ const response = await fetch('/api/auth/logout', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ if (response.ok) {
+ window.location.href = '/login.html';
+ } else {
+ showStatus('Logout failed. Please try again.', 'error');
+ }
+ } catch (error) {
+ console.error('Logout error:', error);
+ showStatus('Logout failed. Please try again.', 'error');
+ }
+}
+
// Check API configuration
async function checkConfiguration() {
try {
diff --git a/map/app/public/login.html b/map/app/public/login.html
new file mode 100644
index 0000000..2daa46c
--- /dev/null
+++ b/map/app/public/login.html
@@ -0,0 +1,254 @@
+
+
+
+
+
+
+ Login - NocoDB Map Viewer
+
+
+
+
+
+
+
+
+
+
+
diff --git a/nocodb-map-viewer/app/routes/geocoding.js b/map/app/routes/geocoding.js
similarity index 100%
rename from nocodb-map-viewer/app/routes/geocoding.js
rename to map/app/routes/geocoding.js
diff --git a/nocodb-map-viewer/app/server.js b/map/app/server.js
similarity index 64%
rename from nocodb-map-viewer/app/server.js
rename to map/app/server.js
index adc7b70..e571a1e 100644
--- a/nocodb-map-viewer/app/server.js
+++ b/map/app/server.js
@@ -5,6 +5,8 @@ const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const winston = require('winston');
const path = require('path');
+const session = require('express-session');
+const cookieParser = require('cookie-parser');
require('dotenv').config();
// Import geocoding routes
@@ -92,6 +94,25 @@ if (process.env.NOCODB_VIEW_URL && (!process.env.NOCODB_PROJECT_ID || !process.e
}
}
+// Auto-parse login sheet ID if URL is provided
+let LOGIN_SHEET_ID = null;
+if (process.env.NOCODB_LOGIN_SHEET) {
+ // Check if it's a URL or just an ID
+ if (process.env.NOCODB_LOGIN_SHEET.startsWith('http')) {
+ const { projectId, tableId } = parseNocoDBUrl(process.env.NOCODB_LOGIN_SHEET);
+ if (projectId && tableId) {
+ LOGIN_SHEET_ID = tableId;
+ console.log(`Auto-parsed login sheet ID from URL: ${LOGIN_SHEET_ID}`);
+ } else {
+ console.error('Could not parse login sheet URL');
+ }
+ } else {
+ // Assume it's already just the ID
+ LOGIN_SHEET_ID = process.env.NOCODB_LOGIN_SHEET;
+ console.log(`Using login sheet ID: ${LOGIN_SHEET_ID}`);
+ }
+}
+
// Configure logger
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
@@ -110,6 +131,22 @@ const logger = winston.createLogger({
const app = express();
const PORT = process.env.PORT || 3000;
+// Session configuration
+app.use(cookieParser());
+app.use(session({
+ secret: process.env.SESSION_SECRET || 'your-secret-key-change-in-production',
+ resave: false,
+ saveUninitialized: false,
+ cookie: {
+ secure: true, // Enable for HTTPS
+ httpOnly: true,
+ maxAge: 24 * 60 * 60 * 1000, // 24 hours
+ sameSite: 'lax',
+ domain: process.env.COOKIE_DOMAIN || undefined // Add domain support
+ },
+ name: 'nocodb-map-session'
+}));
+
// Security middleware
app.use(helmet({
contentSecurityPolicy: {
@@ -125,30 +162,242 @@ app.use(helmet({
// CORS configuration
app.use(cors({
- origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
- credentials: true
+ origin: function(origin, callback) {
+ // Allow requests with no origin (like mobile apps or curl requests)
+ if (!origin) return callback(null, true);
+
+ const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
+ if (allowedOrigins.includes(origin) || allowedOrigins.includes('*')) {
+ callback(null, true);
+ } else {
+ callback(new Error('Not allowed by CORS'));
+ }
+ },
+ credentials: true,
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
+ allowedHeaders: ['Content-Type', 'Authorization']
}));
-// Rate limiting
+// Trust proxy for Cloudflare
+app.set('trust proxy', true);
+
+// Rate limiting with Cloudflare support
const limiter = rateLimit({
- windowMs: 15 * 60 * 1000, // 15 minutes
- max: 100 // limit each IP to 100 requests per windowMs
+ windowMs: 15 * 60 * 1000,
+ max: 100,
+ keyGenerator: (req) => {
+ // Use CF-Connecting-IP header if available (Cloudflare)
+ return req.headers['cf-connecting-ip'] ||
+ req.headers['x-forwarded-for']?.split(',')[0] ||
+ req.ip;
+ },
+ standardHeaders: true,
+ legacyHeaders: false,
});
const strictLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
- max: 20 // limit location creation to 20 per 15 minutes
+ max: 20,
+ keyGenerator: (req) => {
+ return req.headers['cf-connecting-ip'] ||
+ req.headers['x-forwarded-for']?.split(',')[0] ||
+ req.ip;
+ }
+});
+
+const authLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: process.env.NODE_ENV === 'production' ? 10 : 50, // Increase limit slightly
+ message: 'Too many login attempts, please try again later.',
+ keyGenerator: (req) => {
+ return req.headers['cf-connecting-ip'] ||
+ req.headers['x-forwarded-for']?.split(',')[0] ||
+ req.ip;
+ },
+ standardHeaders: true,
+ legacyHeaders: false,
});
// Middleware
app.use(express.json({ limit: '10mb' }));
-app.use(express.static(path.join(__dirname, 'public')));
+
+// Authentication middleware
+const requireAuth = (req, res, next) => {
+ if (req.session && req.session.authenticated) {
+ next();
+ } else {
+ if (req.xhr || req.headers.accept?.indexOf('json') > -1) {
+ res.status(401).json({ success: false, error: 'Authentication required' });
+ } else {
+ res.redirect('/login.html');
+ }
+ }
+};
+
+// Serve login page without authentication
+app.get('/login.html', (req, res) => {
+ res.sendFile(path.join(__dirname, 'public', 'login.html'));
+});
+
+// Auth routes (no authentication required)
+app.post('/api/auth/login', authLimiter, async (req, res) => {
+ try {
+ // Log request details for debugging
+ logger.info('Login attempt:', {
+ email: req.body.email,
+ ip: req.ip,
+ cfIp: req.headers['cf-connecting-ip'],
+ forwardedFor: req.headers['x-forwarded-for'],
+ userAgent: req.headers['user-agent']
+ });
+
+ const { email } = req.body;
+
+ if (!email) {
+ return res.status(400).json({
+ success: false,
+ error: 'Email is required'
+ });
+ }
+
+ // Validate email format
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ return res.status(400).json({
+ success: false,
+ error: 'Invalid email format'
+ });
+ }
+
+ // Check if login sheet is configured
+ if (!LOGIN_SHEET_ID) {
+ logger.error('NOCODB_LOGIN_SHEET not configured or could not be parsed');
+ return res.status(500).json({
+ success: false,
+ error: 'Authentication system not properly configured'
+ });
+ }
+
+ // Fetch authorized emails from NocoDB
+ const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${LOGIN_SHEET_ID}`;
+
+ logger.info(`Checking authentication for email: ${email}`);
+ logger.debug(`Using login sheet API: ${url}`);
+
+ const response = await axios.get(url, {
+ headers: {
+ 'xc-token': process.env.NOCODB_API_TOKEN,
+ 'Content-Type': 'application/json'
+ },
+ params: {
+ limit: 1000 // Adjust if you have more authorized users
+ }
+ });
+
+ const users = response.data.list || [];
+
+ // Check if email exists in the authorized users list
+ const authorizedUser = users.find(user =>
+ user.Email && user.Email.toLowerCase() === email.toLowerCase()
+ );
+
+ if (authorizedUser) {
+ // Set session
+ req.session.authenticated = true;
+ req.session.userEmail = email;
+ req.session.userName = authorizedUser.Name || email;
+
+ // Force session save before sending response
+ req.session.save((err) => {
+ if (err) {
+ logger.error('Session save error:', err);
+ return res.status(500).json({
+ success: false,
+ error: 'Session error. Please try again.'
+ });
+ }
+
+ logger.info(`User authenticated: ${email}`);
+
+ res.json({
+ success: true,
+ message: 'Login successful',
+ user: {
+ email: email,
+ name: req.session.userName
+ }
+ });
+ });
+ } else {
+ logger.warn(`Authentication failed for email: ${email}`);
+ res.status(401).json({
+ success: false,
+ error: 'Email not authorized. Please contact an administrator.'
+ });
+ }
+
+ } catch (error) {
+ logger.error('Login error:', error.message);
+ res.status(500).json({
+ success: false,
+ error: 'Authentication service error. Please try again later.'
+ });
+ }
+});
+
+app.get('/api/auth/check', (req, res) => {
+ res.json({
+ authenticated: req.session?.authenticated || false,
+ user: req.session?.authenticated ? {
+ email: req.session.userEmail,
+ name: req.session.userName
+ } : null
+ });
+});
+
+app.post('/api/auth/logout', (req, res) => {
+ req.session.destroy((err) => {
+ if (err) {
+ logger.error('Logout error:', err);
+ return res.status(500).json({
+ success: false,
+ error: 'Logout failed'
+ });
+ }
+ res.json({
+ success: true,
+ message: 'Logged out successfully'
+ });
+ });
+});
+
+// Add this after the /api/auth/check route
+app.get('/api/debug/session', (req, res) => {
+ res.json({
+ sessionID: req.sessionID,
+ session: req.session,
+ cookies: req.cookies,
+ authenticated: req.session?.authenticated || false
+ });
+});
+
+// Serve static files with authentication for main app
+app.use(express.static(path.join(__dirname, 'public'), {
+ index: false // Don't serve index.html automatically
+}));
+
+// Protect main app routes
+app.get('/', requireAuth, (req, res) => {
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
+});
+
+// Add geocoding routes (protected)
+app.use('/api/geocode', requireAuth, geocodingRoutes);
+
+// Apply rate limiting to API routes
app.use('/api/', limiter);
-// Add geocoding routes
-app.use('/api/geocode', geocodingRoutes);
-
-// Health check endpoint
+// Health check endpoint (no auth required)
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
@@ -157,15 +406,18 @@ app.get('/health', (req, res) => {
});
});
-// Configuration validation endpoint (for debugging)
-app.get('/api/config-check', (req, res) => {
+// Configuration validation endpoint (protected)
+app.get('/api/config-check', requireAuth, (req, res) => {
const config = {
hasApiUrl: !!process.env.NOCODB_API_URL,
hasApiToken: !!process.env.NOCODB_API_TOKEN,
hasProjectId: !!process.env.NOCODB_PROJECT_ID,
hasTableId: !!process.env.NOCODB_TABLE_ID,
+ hasLoginSheet: !!LOGIN_SHEET_ID,
projectId: process.env.NOCODB_PROJECT_ID,
tableId: process.env.NOCODB_TABLE_ID,
+ loginSheet: LOGIN_SHEET_ID,
+ loginSheetConfigured: process.env.NOCODB_LOGIN_SHEET,
nodeEnv: process.env.NODE_ENV
};
@@ -177,6 +429,9 @@ app.get('/api/config-check', (req, res) => {
});
});
+// All other API routes require authentication
+app.use('/api/*', requireAuth);
+
// Get all locations from NocoDB
app.get('/api/locations', async (req, res) => {
try {
@@ -380,7 +635,8 @@ app.post('/api/locations', strictLimiter, async (req, res) => {
latitude: lat,
longitude: lng,
...additionalData,
- created_at: new Date().toISOString()
+ created_at: new Date().toISOString(),
+ created_by: req.session.userEmail // Track who created the location
};
const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}`;
@@ -428,6 +684,7 @@ app.put('/api/locations/:id', strictLimiter, async (req, res) => {
updateData = syncGeoFields(updateData);
updateData.updated_at = new Date().toISOString();
+ updateData.updated_by = req.session.userEmail; // Track who updated
const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`;
@@ -463,7 +720,7 @@ app.delete('/api/locations/:id', strictLimiter, async (req, res) => {
}
});
- logger.info(`Location ${req.params.id} deleted`);
+ logger.info(`Location ${req.params.id} deleted by ${req.session.userEmail}`);
res.json({
success: true,
@@ -499,6 +756,7 @@ app.listen(PORT, () => {
║ Environment: ${process.env.NODE_ENV || 'development'} ║
║ Project ID: ${process.env.NOCODB_PROJECT_ID} ║
║ Table ID: ${process.env.NOCODB_TABLE_ID} ║
+ ║ Login Sheet: ${LOGIN_SHEET_ID || 'Not Configured'} ║
║ Time: ${new Date().toISOString()} ║
╚════════════════════════════════════════╝
`);
diff --git a/nocodb-map-viewer/app/services/geocoding.js b/map/app/services/geocoding.js
similarity index 100%
rename from nocodb-map-viewer/app/services/geocoding.js
rename to map/app/services/geocoding.js
diff --git a/nocodb-map-viewer/docker-compose.yml b/map/docker-compose.yml
similarity index 100%
rename from nocodb-map-viewer/docker-compose.yml
rename to map/docker-compose.yml