added in the user managment section. Need to also do some updates to the admin menue and whatnot however itll get figured.

This commit is contained in:
admin 2025-07-17 17:48:50 -06:00
parent 88b80bc750
commit 6aae0fee41
22 changed files with 1382 additions and 226 deletions

View File

@ -15,10 +15,12 @@ A containerized web application that visualizes geographic data from NocoDB on a
- 🎯 Configurable map start location
- 📋 Walk Sheet generator for door-to-door canvassing
- 🔗 QR code integration for digital resources
- <EFBFBD> Volunteer shift management system with calendar and grid views
- 📅 Volunteer shift management system with calendar and grid views
- ✋ User shift signup and cancellation with color-coded calendar
- 👥 Admin shift creation and management
- <20>🐳 Docker containerization for easy deployment
- 👨‍💼 User management panel for admin users (create, delete users)
- 🔐 Role-based access control (Admin vs User permissions)
- 🐳 Docker containerization for easy deployment
- 🆓 100% open source (no proprietary dependencies)
## Quick Start
@ -140,7 +142,7 @@ The build script automatically creates the following table structure:
- `Support Level` (Single Select): Options: "1", "2", "3", "4" (1=Strong Support/Green, 2=Moderate Support/Yellow, 3=Low Support/Orange, 4=No Support/Red)
- `Address` (Single Line Text): Street address
- `Sign` (Checkbox): Has campaign sign
- `Sign Size` (Single Select): Options: "Small", "Medium", "Large"
- `Sign Size` (Single Select): Options: "Regular", "Large", "Unsure"
- `Notes` (Long Text): Additional details and comments
- `title` (Text): Location name (legacy field)
- `category` (Single Select): Classification (legacy field)
@ -272,14 +274,27 @@ Users with admin privileges can access the admin panel at `/admin.html` to confi
- **Live Preview**: See changes as you type
- **Print Optimization**: Proper formatting for printing or PDF export
- **Persistent Storage**: All QR codes and settings saved to NocoDB
- **Real-time Preview**: See changes immediately on the admin map
- **Validation**: Built-in coordinate and zoom level validation
#### Shift Management
- **Create Shifts**: Set up volunteer shifts with dates, times, and capacity
- **Manage Volunteers**: View signups and manage shift participants
- **Real-time Updates**: See shift status changes immediately
#### User Management
- **Create Users**: Add new user accounts with email and password
- **Role Assignment**: Assign admin or user privileges
- **User List**: View all registered users with their details and creation dates
- **Delete Users**: Remove user accounts (with confirmation prompts)
- **Security**: Password validation and admin-only access
### Access Control
- Admin access is controlled via the `Admin` checkbox in the Login table
- Only authenticated users with admin privileges can access `/admin.html`
- Admin status is checked on every request to admin endpoints
- User management functions are restricted to admin users only
### Start Location Priority

View File

@ -6,19 +6,34 @@ const { sanitizeUser, extractId } = require('../utils/helpers');
class UsersController {
async getAll(req, res) {
try {
// Debug logging
logger.info('UsersController.getAll called');
logger.info('loginSheetId from config:', config.nocodb.loginSheetId);
logger.info('NocoDB config:', {
apiUrl: config.nocodb.apiUrl,
hasToken: !!config.nocodb.apiToken,
projectId: config.nocodb.projectId,
tableId: config.nocodb.tableId,
loginSheetId: config.nocodb.loginSheetId
});
if (!config.nocodb.loginSheetId) {
logger.error('Login sheet not configured in environment');
return res.status(500).json({
success: false,
error: 'Login sheet not configured'
error: 'Login sheet not configured. Please set NOCODB_LOGIN_SHEET in your environment variables.'
});
}
logger.info('Fetching users from NocoDB...');
// Remove the sort parameter that's causing the error
const response = await nocodbService.getAll(config.nocodb.loginSheetId, {
limit: 100,
sort: '-created_at'
limit: 100
// Removed: sort: '-created_at'
});
const users = response.list || [];
logger.info(`Retrieved ${users.length} users from database`);
// Remove password field from response for security
const safeUsers = users.map(sanitizeUser);
@ -32,7 +47,7 @@ class UsersController {
logger.error('Error fetching users:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch users'
error: 'Failed to fetch users: ' + error.message
});
}
}
@ -65,7 +80,7 @@ class UsersController {
});
}
// Create new user
// Create new user - use the actual column names from your table
const userData = {
Email: email,
email: email,
@ -74,9 +89,8 @@ class UsersController {
Name: name || '',
name: name || '',
Admin: admin === true,
admin: admin === true,
'Created At': new Date().toISOString(),
created_at: new Date().toISOString()
admin: admin === true
// Removed created_at fields as they might not exist
};
const response = await nocodbService.create(

View File

@ -38,6 +38,7 @@
<a href="#start-location" class="active">Start Location</a>
<a href="#walk-sheet">Walk Sheet</a>
<a href="#shifts">Shifts</a>
<a href="#users">User Management</a>
</nav>
</div>
@ -239,6 +240,71 @@
</div>
</div>
</section>
<!-- Users Section -->
<section id="users" class="admin-section" style="display: none;">
<h2>User Management</h2>
<p>Create and manage user accounts for the application.</p>
<div class="users-admin-container">
<div class="user-form">
<h3>Create New User</h3>
<form id="user-form">
<div class="form-group">
<label for="user-email">Email *</label>
<input type="email" id="user-email" required>
</div>
<div class="form-group">
<label for="user-password">Password *</label>
<input type="password" id="user-password" required minlength="6">
</div>
<div class="form-group">
<label for="user-name">Name</label>
<input type="text" id="user-name">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="user-admin"> Admin Access
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Create User</button>
<button type="button" class="btn btn-secondary" id="clear-user-form">Clear</button>
</div>
</form>
</div>
<div class="users-list">
<h3>Existing Users</h3>
<div id="users-table-container">
<table id="users-table" class="users-table">
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th>Admin</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-table-body">
<!-- Users will be loaded here -->
</tbody>
</table>
</div>
<div id="users-loading" class="loading-message" style="display: none;">
Loading users...
</div>
<div id="users-empty" class="empty-message" style="display: none;">
No users found.
</div>
</div>
</div>
</section>
</div>
</div>

View File

@ -564,90 +564,297 @@
color: var(--warning-color);
}
/* Responsive - Scale down for smaller screens */
@media (max-width: 1400px) {
.walk-sheet-preview .walk-sheet-page {
transform: scale(0.85);
transform-origin: top center;
}
.walk-sheet-preview {
min-height: 850px;
}
/* User Management Styles */
.users-admin-container {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 30px;
margin-top: 20px;
}
/* Mobile/Small Screen Layout - Stack config above preview */
@media (max-width: 1200px) {
.walk-sheet-container {
display: flex !important; /* Change from grid to flex */
flex-direction: column !important; /* Stack vertically */
.user-form,
.users-list {
background: white;
border-radius: var(--border-radius);
padding: 25px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 1px solid #e0e0e0;
}
.user-form h3,
.users-list h3 {
margin-bottom: 20px;
color: var(--dark-color);
border-bottom: 2px solid var(--primary-color);
padding-bottom: 10px;
font-size: 18px;
}
.users-table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
font-size: 14px;
}
.users-table th,
.users-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.users-table th {
background-color: #f8f9fa;
font-weight: 600;
color: var(--dark-color);
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.users-table tbody tr {
transition: background-color 0.2s;
}
.users-table tbody tr:hover {
background-color: #f8f9fa;
}
.user-role {
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.user-role.admin {
background-color: #dc3545;
color: white;
}
.user-role.user {
background-color: #28a745;
color: white;
}
.user-actions {
display: flex;
gap: 8px;
}
.user-actions .btn {
padding: 6px 12px;
font-size: 12px;
border-radius: var(--border-radius);
font-weight: 500;
}
.btn-danger {
background-color: #dc3545;
color: white;
border: 1px solid #dc3545;
transition: var(--transition);
}
.btn-danger:hover {
background-color: #c82333;
border-color: #c82333;
transform: translateY(-1px);
}
.loading-message,
.empty-message {
text-align: center;
padding: 40px 20px;
color: #666;
font-style: italic;
background-color: #f8f9fa;
border-radius: var(--border-radius);
margin-top: 15px;
}
.loading-message {
background-color: #e3f2fd;
color: #1976d2;
}
/* User form specific styles */
.user-form .form-group {
margin-bottom: 20px;
}
.user-form .form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--dark-color);
font-size: 14px;
}
.user-form .form-group label input[type="checkbox"] {
margin-right: 8px;
transform: scale(1.1);
}
.user-form input[type="email"],
.user-form input[type="password"],
.user-form input[type="text"] {
width: 100%;
padding: 12px;
border: 2px solid #ddd;
border-radius: var(--border-radius);
font-size: 14px;
transition: var(--transition);
background-color: #fafafa;
}
.user-form input:focus {
outline: none;
border-color: var(--primary-color);
background-color: white;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
.user-form .form-actions {
display: flex;
gap: 10px;
margin-top: 25px;
}
.user-form .form-actions .btn {
flex: 1;
padding: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 13px;
}
/* Responsive design for user management */
@media (max-width: 768px) {
.users-admin-container {
grid-template-columns: 1fr;
gap: 20px;
}
.walk-sheet-config {
order: 1 !important; /* Config first */
margin-bottom: 20px;
.users-table {
font-size: 12px;
}
.walk-sheet-preview {
order: 2 !important; /* Preview second */
padding: 20px;
min-height: auto;
max-width: 100vw;
overflow-x: auto; /* Allow horizontal scroll if needed */
display: flex;
justify-content: center; /* Center the page */
.users-table th,
.users-table td {
padding: 8px;
}
.walk-sheet-preview .walk-sheet-page {
transform: scale(0.75);
transform-origin: top center;
margin-bottom: -200px;
max-width: 100%; /* Prevent overflow */
.user-actions {
flex-direction: column;
gap: 4px;
}
}
@media (max-width: 1000px) {
.walk-sheet-preview .walk-sheet-page {
transform: scale(0.5);
margin-bottom: -400px;
.user-actions .btn {
font-size: 11px;
padding: 4px 8px;
}
}
@media (max-width: 768px) {
.user-form .form-actions {
flex-direction: column;
}
/* Admin container mobile layout */
.admin-container {
flex-direction: column;
}
.admin-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid #e0e0e0;
padding: 15px;
}
.admin-nav {
flex-direction: row;
overflow-x: auto;
gap: 10px;
padding-bottom: 10px;
}
.admin-nav a {
white-space: nowrap;
min-width: auto;
padding: 8px 12px;
font-size: 14px;
}
.header .header-actions {
display: flex !important;
gap: 10px;
flex-wrap: wrap;
}
.header .header-actions .btn {
padding: 6px 10px;
font-size: 13px;
}
.admin-info {
font-size: 12px;
}
.admin-map-container {
grid-template-columns: 1fr;
gap: 15px;
}
.admin-map {
height: 220px;
height: 250px;
}
.admin-content {
padding: 8px;
padding: 15px;
}
.admin-section {
padding: 10px;
padding: 15px;
}
.form-row {
grid-template-columns: 1fr;
}
/* Shifts admin mobile */
.shifts-admin-container {
grid-template-columns: 1fr;
gap: 20px;
}
.shift-admin-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.shift-actions {
width: 100%;
justify-content: flex-end;
}
/* Walk sheet container mobile */
.walk-sheet-container {
display: flex !important;
flex-direction: column !important;
gap: 20px;
}
.walk-sheet-config {
order: 1 !important;
margin-bottom: 20px;
}
/* Walk sheet mobile */
.walk-sheet-preview {
min-width: 0;
max-width: 100%;
@ -673,20 +880,124 @@
left: 50%;
margin-left: -408px; /* Half of 816px to center it */
}
}
/* Even smaller screens */
@media (max-width: 480px) {
.walk-sheet-preview {
padding: 5px;
width: 100%;
/* Status container mobile */
.status-container {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
display: flex;
flex-direction: column;
align-items: center;
}
.walk-sheet-preview #walk-sheet-preview-content {
.status-message {
font-size: 13px;
padding: 10px 12px;
max-width: 90%;
text-align: center;
}
}
@media (max-width: 480px) {
.users-table {
font-size: 11px;
}
.user-role {
font-size: 10px;
padding: 2px 6px;
}
.user-form input[type="email"],
.user-form input[type="password"],
.user-form input[type="text"] {
padding: 10px;
font-size: 13px;
}
/* Very small screen adjustments */
.admin-content {
padding: 10px;
}
.admin-section {
padding: 10px;
}
.admin-sidebar {
padding: 10px;
}
.admin-nav a {
padding: 6px 10px;
font-size: 13px;
}
.header .header-actions .btn {
padding: 5px 8px;
font-size: 12px;
}
.admin-info {
display: none; /* Hide on very small screens */
}
.admin-map {
height: 200px;
}
/* Walk sheet very small screens */
.walk-sheet-preview {
padding: 10px;
width: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
}
.walk-sheet-preview .walk-sheet-page {
transform: scale(0.25);
transform-origin: center top;
margin-bottom: -750px;
left: 50%;
margin-left: -408px; /* Keep centered */
margin-left: auto;
margin-right: auto;
}
/* Form adjustments */
.form-group input,
.form-group select {
font-size: 16px; /* Prevent zoom on iOS */
}
.btn {
min-height: 44px; /* Touch-friendly button size */
padding: 10px 16px;
}
.btn-sm {
min-height: 36px;
padding: 8px 12px;
}
/* User management specific mobile adjustments */
.user-container {
padding: 0.5rem;
}
.user-profile {
padding: 1rem;
}
.users-table th {
font-size: 10px;
}
.user-actions .btn {
font-size: 10px;
padding: 3px 6px;
min-height: 32px;
}
}
@ -813,3 +1124,150 @@
grid-template-columns: 1fr;
}
}
/* Additional mobile styles for better responsiveness */
@media (max-width: 1200px) {
.walk-sheet-container {
display: flex !important;
flex-direction: column !important;
gap: 20px;
}
.walk-sheet-config {
order: 1 !important;
margin-bottom: 20px;
}
.walk-sheet-preview {
order: 2 !important;
padding: 20px;
min-height: auto;
max-width: 100vw;
overflow-x: auto;
display: flex;
justify-content: center;
align-items: flex-start;
}
.walk-sheet-preview .walk-sheet-page {
transform: scale(0.75);
transform-origin: center top;
margin-bottom: -200px;
max-width: 100%;
margin-left: auto;
margin-right: auto;
}
}
@media (max-width: 1000px) {
.walk-sheet-preview .walk-sheet-page {
transform: scale(0.5);
transform-origin: center top;
margin-bottom: -400px;
margin-left: auto;
margin-right: auto;
}
}
/* Tablet and mobile header adjustments */
@media (max-width: 1024px) {
.header {
padding: 10px 15px;
}
.header h1 {
font-size: 20px;
}
.header .header-actions {
gap: 8px;
}
}
/* Responsive Table Styles for Mobile */
@media (max-width: 640px) {
.users-table {
border: 0;
}
.users-table thead {
border: none;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
.users-table tr {
border-bottom: 3px solid #ddd;
display: block;
margin-bottom: 10px;
padding: 10px;
background: #f8f9fa;
border-radius: 6px;
}
.users-table td {
border: none;
border-bottom: 1px solid #eee;
display: block;
font-size: 13px;
text-align: right;
padding: 8px 10px;
}
.users-table td::before {
content: attr(data-label);
float: left;
font-weight: bold;
text-transform: uppercase;
font-size: 11px;
color: #666;
}
.users-table td:last-child {
border-bottom: 0;
}
.user-actions {
justify-content: flex-end;
margin-top: 8px;
}
.user-actions .btn {
width: auto !important;
min-width: 80px;
flex: none;
}
}
/* Enhanced mobile form styling */
@media (max-width: 480px) {
.user-form input[type="email"],
.user-form input[type="password"],
.user-form input[type="text"] {
font-size: 16px; /* Prevent zoom on iOS */
padding: 12px;
}
.user-form .form-group label {
font-size: 14px;
}
.user-form .form-actions .btn {
width: 100%;
margin-bottom: 8px;
}
.users-table td {
font-size: 12px;
padding: 6px 8px;
}
.users-table td::before {
font-size: 10px;
}
}

324
map/app/public/css/user.css Normal file
View File

@ -0,0 +1,324 @@
/* User Profile Styles */
.user-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.user-profile {
background: #ffffff;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 1px solid #e0e0e0;
}
.user-profile h2 {
margin-bottom: 1.5rem;
color: #333;
border-bottom: 2px solid #007bff;
padding-bottom: 0.5rem;
}
.profile-info {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-info .form-group {
display: flex;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid #f0f0f0;
}
.profile-info .form-group:last-child {
border-bottom: none;
}
.profile-info label {
font-weight: 600;
color: #555;
min-width: 100px;
margin-right: 1rem;
}
.profile-info span {
color: #333;
flex: 1;
}
/* Users Admin Table Styles (for admin panel) */
.users-admin-container {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 2rem;
margin-top: 1rem;
}
.user-form,
.users-list {
background: #ffffff;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 1px solid #e0e0e0;
}
.user-form h3,
.users-list h3 {
margin-bottom: 1rem;
color: #333;
border-bottom: 2px solid #007bff;
padding-bottom: 0.5rem;
}
.users-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.users-table th,
.users-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.users-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #555;
}
.users-table tbody tr:hover {
background-color: #f8f9fa;
}
.user-role {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.user-role.admin {
background-color: #dc3545;
color: white;
}
.user-role.user {
background-color: #28a745;
color: white;
}
.user-actions {
display: flex;
gap: 0.5rem;
}
.user-actions .btn {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.btn-danger {
background-color: #dc3545;
color: white;
border: 1px solid #dc3545;
}
.btn-danger:hover {
background-color: #c82333;
border-color: #c82333;
}
.loading-message,
.empty-message {
text-align: center;
padding: 2rem;
color: #666;
font-style: italic;
}
/* Form styles specific to user creation */
.user-form .form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #333;
}
.user-form .form-group label input[type="checkbox"] {
margin-right: 0.5rem;
}
.user-form input[type="email"],
.user-form input[type="password"],
.user-form input[type="text"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.user-form input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
/* Responsive design */
@media (max-width: 768px) {
.users-admin-container {
grid-template-columns: 1fr;
gap: 1rem;
}
.users-table {
font-size: 0.9rem;
}
.users-table th,
.users-table td {
padding: 0.5rem;
}
.user-container {
padding: 1rem;
}
/* Header mobile adjustments */
.header {
padding: 10px 15px;
}
.header h1 {
font-size: 20px;
}
.header .header-actions {
display: flex !important;
gap: 8px;
flex-wrap: wrap;
}
.header .header-actions .btn {
padding: 6px 10px;
font-size: 13px;
}
.user-info {
font-size: 12px;
}
/* User profile mobile */
.user-profile {
padding: 1.5rem;
}
.profile-info .form-group {
padding: 0.5rem 0;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.profile-info label {
min-width: auto;
margin-right: 0;
font-size: 14px;
}
.profile-info span {
font-size: 14px;
}
/* Ensure all buttons are touch-friendly */
.btn {
min-height: 44px;
padding: 12px 16px;
font-size: 14px;
}
.btn-sm {
min-height: 36px;
padding: 8px 12px;
font-size: 13px;
}
/* Better spacing for form elements */
.form-group {
margin-bottom: 20px;
}
/* Improve input focus visibility */
input:focus,
select:focus,
textarea:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}
}
@media (max-width: 480px) {
.users-table {
font-size: 0.8rem;
}
.user-actions {
flex-direction: column;
gap: 0.25rem;
}
.user-actions .btn {
font-size: 0.7rem;
}
.user-container {
padding: 0.5rem;
}
/* Very small screen adjustments */
.header .header-actions .btn {
padding: 5px 8px;
font-size: 12px;
min-height: 36px;
}
.user-info {
display: none; /* Hide on very small screens to save space */
}
.user-profile {
padding: 1rem;
}
.user-profile h2 {
font-size: 18px;
margin-bottom: 1rem;
}
.profile-info .form-group {
padding: 0.4rem 0;
}
.profile-info label {
font-size: 13px;
font-weight: 500;
}
.profile-info span {
font-size: 13px;
}
/* Touch-friendly logout button */
#logout-btn {
min-height: 44px;
padding: 10px 16px;
}
}

View File

@ -188,9 +188,9 @@
<label for="edit-sign-size">Sign Size</label>
<select id="edit-sign-size" name="Sign Size">
<option value="">-- Select --</option>
<option value="Small">Small</option>
<option value="Medium">Medium</option>
<option value="Regular">Regular</option>
<option value="Large">Large</option>
<option value="Unsure">Unsure</option>
</select>
</div>
</div>
@ -299,9 +299,9 @@
<label for="sign-size">Sign Size</label>
<select id="sign-size" name="Sign Size">
<option value="">-- Select --</option>
<option value="Small">Small</option>
<option value="Medium">Medium</option>
<option value="Regular">Regular</option>
<option value="Large">Large</option>
<option value="Unsure">Unsure</option>
</select>
</div>
</div>

View File

@ -260,6 +260,18 @@ function setupEventListeners() {
if (clearShiftBtn) {
clearShiftBtn.addEventListener('click', clearShiftForm);
}
// User form submission
const userForm = document.getElementById('user-form');
if (userForm) {
userForm.addEventListener('submit', createUser);
}
// Clear user form button
const clearUserBtn = document.getElementById('clear-user-form');
if (clearUserBtn) {
clearUserBtn.addEventListener('click', clearUserForm);
}
}
// Setup navigation between admin sections
@ -297,6 +309,12 @@ function setupNavigation() {
loadAdminShifts();
}
// If switching to users section, load users
if (targetId === 'users') {
console.log('Loading users...');
loadUsers();
}
// If switching to walk sheet section, load config
if (targetId === 'walk-sheet') {
loadWalkSheetConfig().then((success) => {
@ -537,9 +555,9 @@ function generateWalkSheetPreview() {
<div class="ws-form-group">
<label class="ws-form-label">Sign Size</label>
<div class="ws-form-field circles">
<span class="ws-circle-option"><span class="ws-circle">S</span></span>
<span class="ws-circle-option"><span class="ws-circle">M</span></span>
<span class="ws-circle-option"><span class="ws-circle">R</span></span>
<span class="ws-circle-option"><span class="ws-circle">L</span></span>
<span class="ws-circle-option"><span class="ws-circle">U</span></span>
</div>
</div>
<div class="ws-form-group">
@ -955,62 +973,6 @@ function displayAdminShifts(shifts) {
setupShiftActionListeners();
}
// Fix the setupNavigation function to properly load shifts when switching to shifts section
function setupNavigation() {
const navLinks = document.querySelectorAll('.admin-nav a');
const sections = document.querySelectorAll('.admin-section');
navLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
// Get target section ID
const targetId = link.getAttribute('href').substring(1);
// Hide all sections
sections.forEach(section => {
section.style.display = 'none';
});
// Show target section
const targetSection = document.getElementById(targetId);
if (targetSection) {
targetSection.style.display = 'block';
}
// Update active nav link
navLinks.forEach(navLink => {
navLink.classList.remove('active');
});
link.classList.add('active');
// If switching to shifts section, load shifts
if (targetId === 'shifts') {
console.log('Loading admin shifts...');
loadAdminShifts();
}
// If switching to walk sheet section, load config
if (targetId === 'walk-sheet') {
loadWalkSheetConfig().then((success) => {
if (success) {
generateWalkSheetPreview();
}
});
}
});
});
// Also check if we're already on the shifts page (via hash)
const hash = window.location.hash;
if (hash === '#shifts') {
const shiftsLink = document.querySelector('.admin-nav a[href="#shifts"]');
if (shiftsLink) {
shiftsLink.click();
}
}
}
// Fix the setupShiftActionListeners function
function setupShiftActionListeners() {
const list = document.getElementById('admin-shifts-list');
@ -1112,101 +1074,181 @@ function clearShiftForm() {
}
}
// Update setupEventListeners to include shift form and clear button
function setupEventListeners() {
// Use current view button
const useCurrentViewBtn = document.getElementById('use-current-view');
if (useCurrentViewBtn) {
useCurrentViewBtn.addEventListener('click', () => {
const center = adminMap.getCenter();
const zoom = adminMap.getZoom();
document.getElementById('start-lat').value = center.lat.toFixed(6);
document.getElementById('start-lng').value = center.lng.toFixed(6);
document.getElementById('start-zoom').value = zoom;
updateStartMarker(center.lat, center.lng);
showStatus('Captured current map view', 'success');
});
}
// User Management Functions
async function loadUsers() {
const loadingEl = document.getElementById('users-loading');
const emptyEl = document.getElementById('users-empty');
const tableBody = document.getElementById('users-table-body');
// Save button
const saveLocationBtn = document.getElementById('save-start-location');
if (saveLocationBtn) {
saveLocationBtn.addEventListener('click', saveStartLocation);
}
if (loadingEl) loadingEl.style.display = 'block';
if (emptyEl) emptyEl.style.display = 'none';
if (tableBody) tableBody.innerHTML = '';
// Coordinate input changes
const startLatInput = document.getElementById('start-lat');
const startLngInput = document.getElementById('start-lng');
const startZoomInput = document.getElementById('start-zoom');
if (startLatInput) startLatInput.addEventListener('change', updateMapFromInputs);
if (startLngInput) startLngInput.addEventListener('change', updateMapFromInputs);
if (startZoomInput) startZoomInput.addEventListener('change', updateMapFromInputs);
// Walk Sheet buttons
const saveWalkSheetBtn = document.getElementById('save-walk-sheet');
const previewWalkSheetBtn = document.getElementById('preview-walk-sheet');
const printWalkSheetBtn = document.getElementById('print-walk-sheet');
const refreshPreviewBtn = document.getElementById('refresh-preview');
if (saveWalkSheetBtn) saveWalkSheetBtn.addEventListener('click', saveWalkSheetConfig);
if (previewWalkSheetBtn) previewWalkSheetBtn.addEventListener('click', generateWalkSheetPreview);
if (printWalkSheetBtn) printWalkSheetBtn.addEventListener('click', printWalkSheet);
if (refreshPreviewBtn) refreshPreviewBtn.addEventListener('click', generateWalkSheetPreview);
// Auto-update preview on input change
const walkSheetInputs = document.querySelectorAll(
'#walk-sheet-title, #walk-sheet-subtitle, #walk-sheet-footer, ' +
'[id^="qr-code-"][id$="-url"], [id^="qr-code-"][id$="-label"]'
);
walkSheetInputs.forEach(input => {
if (input) {
input.addEventListener('input', debounce(() => {
generateWalkSheetPreview();
}, 500));
try {
const response = await fetch('/api/users');
const data = await response.json();
if (loadingEl) loadingEl.style.display = 'none';
if (data.success && data.users) {
displayUsers(data.users);
} else {
throw new Error(data.error || 'Failed to load users');
}
});
// Add URL change listeners to detect when QR codes need regeneration
for (let i = 1; i <= 3; i++) {
const urlInput = document.getElementById(`qr-code-${i}-url`);
if (urlInput) {
let previousUrl = urlInput.value;
urlInput.addEventListener('change', () => {
const currentUrl = urlInput.value;
if (currentUrl !== previousUrl) {
console.log(`QR Code ${i} URL changed from "${previousUrl}" to "${currentUrl}"`);
// Remove stored QR code so it gets regenerated
delete storedQRCodes[currentUrl];
previousUrl = currentUrl;
generateWalkSheetPreview();
}
});
} catch (error) {
console.error('Error loading users:', error);
if (loadingEl) loadingEl.style.display = 'none';
if (emptyEl) {
emptyEl.textContent = 'Failed to load users';
emptyEl.style.display = 'block';
}
}
// Shift form submission
const shiftForm = document.getElementById('shift-form');
if (shiftForm) {
shiftForm.addEventListener('submit', createShift);
}
// Clear shift form button
const clearShiftBtn = document.getElementById('clear-shift-form');
if (clearShiftBtn) {
clearShiftBtn.addEventListener('click', clearShiftForm);
showStatus('Failed to load users', 'error');
}
}
// Add the missing clearShiftForm function
function clearShiftForm() {
const form = document.getElementById('shift-form');
if (form) {
form.reset();
showStatus('Form cleared', 'info');
function displayUsers(users) {
const tableBody = document.getElementById('users-table-body');
const emptyEl = document.getElementById('users-empty');
if (!tableBody) return;
if (!users || users.length === 0) {
if (emptyEl) emptyEl.style.display = 'block';
return;
}
if (emptyEl) emptyEl.style.display = 'none';
tableBody.innerHTML = users.map(user => {
const createdDate = user.created_at || user['Created At'] || user.createdAt;
const formattedDate = createdDate ? new Date(createdDate).toLocaleDateString() : 'N/A';
const isAdmin = user.admin || user.Admin || false;
const userId = user.Id || user.id || user.ID;
return `
<tr>
<td data-label="Email">${escapeHtml(user.email || user.Email || 'N/A')}</td>
<td data-label="Name">${escapeHtml(user.name || user.Name || 'N/A')}</td>
<td data-label="Role">
<span class="user-role ${isAdmin ? 'admin' : 'user'}">
${isAdmin ? 'Admin' : 'User'}
</span>
</td>
<td data-label="Created">${formattedDate}</td>
<td data-label="Actions">
<div class="user-actions">
<button class="btn btn-danger delete-user-btn" data-user-id="${userId}" data-user-email="${escapeHtml(user.email || user.Email)}">
Delete
</button>
</div>
</td>
</tr>
`;
}).join('');
// Setup event listeners for user actions
setupUserActionListeners();
}
function setupUserActionListeners() {
const tableBody = document.getElementById('users-table-body');
if (!tableBody) return;
// Remove existing listeners by cloning the node
const newTableBody = tableBody.cloneNode(true);
tableBody.parentNode.replaceChild(newTableBody, tableBody);
// Get the updated reference
const updatedTableBody = document.getElementById('users-table-body');
updatedTableBody.addEventListener('click', function(e) {
if (e.target.classList.contains('delete-user-btn')) {
const userId = e.target.getAttribute('data-user-id');
const userEmail = e.target.getAttribute('data-user-email');
console.log('Delete button clicked for user:', userId);
deleteUser(userId, userEmail);
}
});
}
async function deleteUser(userId, userEmail) {
if (!confirm(`Are you sure you want to delete user "${userEmail}"? This action cannot be undone.`)) {
return;
}
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showStatus(`User "${userEmail}" deleted successfully`, 'success');
loadUsers(); // Reload the users list
} else {
throw new Error(data.error || 'Failed to delete user');
}
} catch (error) {
console.error('Error deleting user:', error);
showStatus(`Failed to delete user: ${error.message}`, 'error');
}
}
async function createUser(e) {
e.preventDefault();
const email = document.getElementById('user-email').value.trim();
const password = document.getElementById('user-password').value;
const name = document.getElementById('user-name').value.trim();
const admin = document.getElementById('user-admin').checked;
if (!email || !password) {
showStatus('Email and password are required', 'error');
return;
}
if (password.length < 6) {
showStatus('Password must be at least 6 characters long', 'error');
return;
}
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email,
password,
name: name || '',
admin
})
});
const data = await response.json();
if (data.success) {
showStatus('User created successfully', 'success');
clearUserForm();
loadUsers(); // Reload the users list
} else {
throw new Error(data.error || 'Failed to create user');
}
} catch (error) {
console.error('Error creating user:', error);
showStatus(`Failed to create user: ${error.message}`, 'error');
}
}
function clearUserForm() {
const form = document.getElementById('user-form');
if (form) {
form.reset();
showStatus('User form cleared', 'info');
}
}

97
map/app/public/js/user.js Normal file
View File

@ -0,0 +1,97 @@
// User profile JavaScript
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
checkUserAuth();
loadUserProfile();
setupEventListeners();
});
// Check if user is authenticated
async function checkUserAuth() {
try {
const response = await fetch('/api/auth/check');
const data = await response.json();
if (!data.authenticated) {
window.location.href = '/login.html';
return;
}
// Update user info in header
const userInfo = document.getElementById('user-info');
if (userInfo && data.user) {
userInfo.textContent = `${data.user.name || data.user.email}`;
}
} catch (error) {
console.error('Auth check failed:', error);
window.location.href = '/login.html';
}
}
// Load user profile information
async function loadUserProfile() {
try {
const response = await fetch('/api/auth/check');
const data = await response.json();
if (data.authenticated && data.user) {
document.getElementById('profile-email').textContent = data.user.email || 'N/A';
document.getElementById('profile-name').textContent = data.user.name || 'N/A';
document.getElementById('profile-role').textContent = data.user.isAdmin ? 'Administrator' : 'User';
}
} catch (error) {
console.error('Failed to load profile:', error);
showStatus('Failed to load profile information', 'error');
}
}
// Setup event listeners
function setupEventListeners() {
// Logout button
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', handleLogout);
}
}
// Handle logout
async function handleLogout() {
try {
const response = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
window.location.href = '/login.html';
} else {
throw new Error('Logout failed');
}
} catch (error) {
console.error('Logout error:', error);
showStatus('Logout failed', 'error');
}
}
// Show status message
function showStatus(message, type = 'info') {
const container = document.getElementById('status-container');
if (!container) return;
const statusDiv = document.createElement('div');
statusDiv.className = `status-message status-${type}`;
statusDiv.textContent = message;
container.appendChild(statusDiv);
// Auto remove after 5 seconds
setTimeout(() => {
if (statusDiv.parentNode) {
statusDiv.parentNode.removeChild(statusDiv);
}
}, 5000);
}

59
map/app/public/user.html Normal file
View File

@ -0,0 +1,59 @@
<!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="User Profile - BNKops Map - Interactive canvassing web-app & viewer">
<title>User Profile</title>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="shortcut icon" href="/favicon.ico">
<!-- Custom CSS -->
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/user.css">
</head>
<body>
<div id="app">
<!-- Header -->
<header class="header">
<h1>User Profile</h1>
<div class="header-actions">
<a href="/" class="btn btn-secondary">← Back to Map</a>
<span id="user-info" class="user-info"></span>
<button id="logout-btn" class="btn btn-secondary">Logout</button>
</div>
</header>
<!-- Main Content -->
<div class="user-container">
<div class="user-profile">
<h2>Profile Information</h2>
<div class="profile-info">
<div class="form-group">
<label>Email:</label>
<span id="profile-email"></span>
</div>
<div class="form-group">
<label>Name:</label>
<span id="profile-name"></span>
</div>
<div class="form-group">
<label>Role:</label>
<span id="profile-role"></span>
</div>
</div>
</div>
</div>
<!-- Status Messages -->
<div id="status-container" class="status-container"></div>
</div>
<!-- User JavaScript -->
<script src="js/user.js"></script>
</body>
</html>

View File

@ -2,6 +2,26 @@ const express = require('express');
const router = express.Router();
const settingsController = require('../controllers/settingsController');
// Debug endpoint to check configuration
router.get('/config-debug', (req, res) => {
const config = require('../config');
res.json({
success: true,
config: {
nocodb: {
apiUrl: config.nocodb.apiUrl,
hasToken: !!config.nocodb.apiToken,
projectId: config.nocodb.projectId,
tableId: config.nocodb.tableId,
loginSheetId: config.nocodb.loginSheetId,
settingsSheetId: config.nocodb.settingsSheetId,
shiftsSheetId: config.nocodb.shiftsSheetId,
shiftSignupsSheetId: config.nocodb.shiftSignupsSheetId
}
}
});
});
// Start location management
router.get('/start-location', settingsController.getStartLocation);
router.post('/start-location', settingsController.updateStartLocation);

View File

@ -222,4 +222,43 @@ router.get('/walk-sheet-raw', async (req, res) => {
}
});
// Add this route to check login table structure
router.get('/login-structure', async (req, res) => {
try {
const loginSheetId = config.nocodb.loginSheetId;
if (!loginSheetId) {
return res.status(400).json({
success: false,
error: 'Login sheet ID not configured'
});
}
// Get table structure
const tableId = extractTableId(loginSheetId);
const response = await nocodbService.api.get(`/db/meta/tables/${tableId}`);
const columns = response.data.columns.map(col => ({
column_name: col.column_name,
title: col.title,
uidt: col.uidt,
required: col.rqd
}));
res.json({
success: true,
tableId,
columns,
columnNames: columns.map(c => c.column_name)
});
} catch (error) {
logger.error('Error fetching login table structure:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
module.exports = router;

View File

@ -102,6 +102,11 @@ module.exports = (app) => {
res.sendFile(path.join(__dirname, '../public', 'shifts.html'));
});
// User profile page route
app.get('/user.html', requireAuth, (req, res) => {
res.sendFile(path.join(__dirname, '../public', 'user.html'));
});
// Catch all - redirect to login
app.get('*', (req, res) => {
res.redirect('/login.html');

View File

@ -344,7 +344,7 @@ Updated the build-nocodb.sh script to use proper NocoDB column types based on th
- `unit_number` (SingleLineText)
- `support_level` (SingleSelect with colors: 1=Green, 2=Yellow, 3=Orange, 4=Red)
- `sign` (Checkbox)
- `sign_size` (SingleSelect: Small, Medium, Large)
- `sign_size` (SingleSelect: Regular, Large, Unsure)
- `notes` (LongText)
- `address` (SingleLineText instead of LongText)

View File

@ -343,9 +343,9 @@ create_locations_table() {
"rqd": false,
"colOptions": {
"options": [
{"title": "Small", "color": "#2196F3"},
{"title": "Medium", "color": "#FF9800"},
{"title": "Large", "color": "#4CAF50"}
{"title": "Regular", "color": "#2196F3"},
{"title": "Large", "color": "#4CAF50"},
{"title": "Unsure", "color": "#FF9800"}
]
}
},

View File

@ -92,7 +92,7 @@ Winston logger configuration for backend logging.
# app/public/admin.html
Admin panel HTML page for managing start location, walk sheet, and settings.
Admin panel HTML page for managing start location, walk sheet, shift management, and user management.
# app/public/css/admin.css
@ -122,9 +122,21 @@ Login page HTML for user authentication.
Volunteer shifts management and signup page HTML with both grid and calendar view options.
# app/public/user.html
User profile page HTML for displaying user information and account management.
# app/public/css/user.css
CSS styles for the user profile page and user management components in the admin panel.
# app/public/js/admin.js
JavaScript for admin panel functionality (map, start location, walk sheet, etc).
JavaScript for admin panel functionality (map, start location, walk sheet, shift management, and user management).
# app/public/js/user.js
JavaScript for user profile page functionality and user account management.
# app/public/js/auth.js

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@ -133,7 +133,7 @@ Main table for storing map data:
- 3 = Low Support (Orange)
- 4 = No Support (Red)
- `Sign` (Checkbox): Has campaign sign
- `Sign Size` (Single Select): Options: "Small", "Medium", "Large"
- `Sign Size` (Single Select): Options: "Regular", "Large", "Unsure"
- `Notes` (Long Text): Additional details and comments
#### 2. Login Table

View File

@ -0,0 +1,4 @@
# Canvas
This is BNKops canvassing how to! In the following document, you will find all sorts of tips and tricks for door knocking, canvassing, and using the BNKops canvassing app.

View File

@ -2,7 +2,8 @@
Quick Tips:
- Map works best when you clear your cookies, cache, and other data before use! This is because it is a web-app that pushes information to your phone. By clearing that data, you will always load the most recent version of the app to your browser.
- **Data:** Map works best when you clear your cookies, cache, and other data before use! This is because it is a web-app that pushes information to your phone. By clearing that data, you will always load the most recent version of the app to your browser.
- **Browser:** Map will work on nearly any browser however the developers test on Firefox, Brave, & Chromium. Firefox is what the bnkops team uses to access Map and is generally the most stable.
## How to add new location - Video

View File

@ -2629,7 +2629,7 @@ Changemaker Archive. <a href="https://docs.bnkops.com">Learn more</a>
<li>3 = Low Support (Orange)</li>
<li>4 = No Support (Red)</li>
<li><code>Sign</code> (Checkbox): Has campaign sign</li>
<li><code>Sign Size</code> (Single Select): Options: "Small", "Medium", "Large"</li>
<li><code>Sign Size</code> (Single Select): Options: "Regular", "Large", "Unsure"</li>
<li><code>Notes</code> (Long Text): Additional details and comments</li>
</ul>
<h4 id="2-login-table">2. Login Table<a class="headerlink" href="#2-login-table" title="Permanent link">&para;</a></h4>

View File

@ -1873,7 +1873,7 @@ Changemaker Archive. <a href="https://docs.bnkops.com">Learn more</a>
<li>4 - No Support (Red)</li>
<li><code>Address</code> (Text): Full street address</li>
<li><code>Sign</code> (Checkbox): Has campaign sign (true/false)</li>
<li><code>Sign Size</code> (Single Select): Small, Medium, Large</li>
<li><code>Sign Size</code> (Single Select): Regular, Large, Unsure</li>
<li><code>Geo-Location</code> (Text): Formatted as "latitude;longitude"</li>
</ul>
<h2 id="api-endpoints">API Endpoints<a class="headerlink" href="#api-endpoints" title="Permanent link">&para;</a></h2>