New login view

This commit is contained in:
admin 2025-06-26 08:37:04 -06:00
parent 91c651da48
commit fa64722020
7 changed files with 626 additions and 12 deletions

View File

@ -3,6 +3,13 @@ NOCODB_API_URL=https://db.lindalindsay.org/api/v1
NOCODB_API_TOKEN=your-api-token-here
NOCODB_VIEW_URL=https://db.lindalindsay.org/dashboard/#/nc/p406kno3lbq4zmq/mvtryxrvze6td79
# Login Sheet Configuration
# You can use either the full URL (recommended) or just the table ID
# Full URL example:
NOCODB_LOGIN_SHEET=https://db.lindalindsay.org/dashboard/#/nc/p406kno3lbq4zmq/mnlzyenzfg1v1mo
# Or just the table ID:
# NOCODB_LOGIN_SHEET=mnlzyenzfg1v1mo
# Auto-parsed from NOCODB_VIEW_URL (no need to set manually)
# NOCODB_PROJECT_ID=p406kno3lbq4zmq
# NOCODB_TABLE_ID=mvtryxrvze6td79
@ -11,6 +18,10 @@ NOCODB_VIEW_URL=https://db.lindalindsay.org/dashboard/#/nc/p406kno3lbq4zmq/mvtry
PORT=3000
NODE_ENV=production
# Session Secret (IMPORTANT: Generate a secure random string for production)
# You can generate one with: openssl rand -hex 32
SESSION_SECRET=your-secure-session-secret-here
# Map Defaults (Edmonton, Alberta, Canada)
DEFAULT_LAT=53.5461
DEFAULT_LNG=-113.4938

View File

@ -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",

View File

@ -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": {

View File

@ -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;

View File

@ -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 = `
<span class="user-email">${escapeHtml(currentUser.email)}</span>
<button id="logout-btn" class="btn btn-secondary btn-sm" title="Sign out">
🚪 Logout
</button>
`;
// 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 {

View File

@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Login to NocoDB Map Viewer">
<title>Login - NocoDB Map Viewer</title>
<!-- Custom CSS -->
<link rel="stylesheet" href="css/style.css">
<style>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--light-color);
padding: 20px;
}
.login-card {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
padding: 40px;
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h1 {
color: var(--dark-color);
font-size: 28px;
margin-bottom: 10px;
}
.login-header p {
color: var(--secondary-color);
font-size: 16px;
}
.login-form {
margin-top: 30px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--dark-color);
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-size: 16px;
transition: var(--transition);
}
.form-group input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(44, 90, 160, 0.1);
}
.login-button {
width: 100%;
padding: 12px 16px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius);
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
.login-button:hover {
background-color: #2471a3;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.login-button:active {
transform: translateY(0);
}
.login-button:disabled {
background-color: var(--secondary-color);
cursor: not-allowed;
transform: none;
}
.error-message {
background-color: #fee;
color: var(--danger-color);
padding: 12px;
border-radius: var(--border-radius);
margin-bottom: 20px;
font-size: 14px;
display: none;
}
.error-message.show {
display: block;
}
.success-message {
background-color: #efe;
color: var(--success-color);
padding: 12px;
border-radius: var(--border-radius);
margin-bottom: 20px;
font-size: 14px;
display: none;
}
.success-message.show {
display: block;
}
.login-footer {
margin-top: 30px;
text-align: center;
color: var(--secondary-color);
font-size: 14px;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>Location Map Viewer</h1>
<p>Please sign in to continue</p>
</div>
<div id="error-message" class="error-message"></div>
<div id="success-message" class="success-message"></div>
<form id="login-form" class="login-form">
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
placeholder="Enter your email address"
required
autocomplete="email"
autofocus
>
</div>
<button type="submit" class="login-button" id="login-button">
Sign In
</button>
</form>
<div class="login-footer">
<p>Access is restricted to authorized users only.</p>
</div>
</div>
</div>
<script>
// Handle login form submission
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const button = document.getElementById('login-button');
const errorMessage = document.getElementById('error-message');
const successMessage = document.getElementById('success-message');
// Clear previous messages
errorMessage.classList.remove('show');
successMessage.classList.remove('show');
// Disable button and show loading state
button.disabled = true;
button.textContent = 'Signing in...';
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email })
});
const data = await response.json();
if (response.ok && data.success) {
successMessage.textContent = 'Login successful! Redirecting...';
successMessage.classList.add('show');
// Redirect to main app after short delay
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
errorMessage.textContent = data.error || 'Invalid email address';
errorMessage.classList.add('show');
button.disabled = false;
button.textContent = 'Sign In';
}
} catch (error) {
console.error('Login error:', error);
errorMessage.textContent = 'An error occurred. Please try again.';
errorMessage.classList.add('show');
button.disabled = false;
button.textContent = 'Sign In';
}
});
// Check if already logged in
fetch('/api/auth/check')
.then(response => response.json())
.then(data => {
if (data.authenticated) {
window.location.href = '/';
}
})
.catch(console.error);
</script>
</body>
</html>

View File

@ -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,19 @@ 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: process.env.NODE_ENV === 'production', // Use secure cookies in production
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Security middleware
app.use(helmet({
contentSecurityPolicy: {
@ -140,15 +174,161 @@ const strictLimiter = rateLimit({
max: 20 // limit location creation to 20 per 15 minutes
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5 // limit login attempts to 5 per 15 minutes
});
// 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 {
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;
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'
});
});
});
// 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 +337,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 +360,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 +566,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 +615,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 +651,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 +687,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()}
`);