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 - 🎯 Configurable map start location
- 📋 Walk Sheet generator for door-to-door canvassing - 📋 Walk Sheet generator for door-to-door canvassing
- 🔗 QR code integration for digital resources - 🔗 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 - ✋ User shift signup and cancellation with color-coded calendar
- 👥 Admin shift creation and management - 👥 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) - 🆓 100% open source (no proprietary dependencies)
## Quick Start ## 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) - `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 - `Address` (Single Line Text): Street address
- `Sign` (Checkbox): Has campaign sign - `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 - `Notes` (Long Text): Additional details and comments
- `title` (Text): Location name (legacy field) - `title` (Text): Location name (legacy field)
- `category` (Single Select): Classification (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 - **Live Preview**: See changes as you type
- **Print Optimization**: Proper formatting for printing or PDF export - **Print Optimization**: Proper formatting for printing or PDF export
- **Persistent Storage**: All QR codes and settings saved to NocoDB - **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 ### Access Control
- Admin access is controlled via the `Admin` checkbox in the Login table - Admin access is controlled via the `Admin` checkbox in the Login table
- Only authenticated users with admin privileges can access `/admin.html` - Only authenticated users with admin privileges can access `/admin.html`
- Admin status is checked on every request to admin endpoints - Admin status is checked on every request to admin endpoints
- User management functions are restricted to admin users only
### Start Location Priority ### Start Location Priority

View File

@ -6,19 +6,34 @@ const { sanitizeUser, extractId } = require('../utils/helpers');
class UsersController { class UsersController {
async getAll(req, res) { async getAll(req, res) {
try { 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) { if (!config.nocodb.loginSheetId) {
logger.error('Login sheet not configured in environment');
return res.status(500).json({ return res.status(500).json({
success: false, 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, { const response = await nocodbService.getAll(config.nocodb.loginSheetId, {
limit: 100, limit: 100
sort: '-created_at' // Removed: sort: '-created_at'
}); });
const users = response.list || []; const users = response.list || [];
logger.info(`Retrieved ${users.length} users from database`);
// Remove password field from response for security // Remove password field from response for security
const safeUsers = users.map(sanitizeUser); const safeUsers = users.map(sanitizeUser);
@ -32,7 +47,7 @@ class UsersController {
logger.error('Error fetching users:', error); logger.error('Error fetching users:', error);
res.status(500).json({ res.status(500).json({
success: false, 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 = { const userData = {
Email: email, Email: email,
email: email, email: email,
@ -74,9 +89,8 @@ class UsersController {
Name: name || '', Name: name || '',
name: name || '', name: name || '',
Admin: admin === true, Admin: admin === true,
admin: admin === true, admin: admin === true
'Created At': new Date().toISOString(), // Removed created_at fields as they might not exist
created_at: new Date().toISOString()
}; };
const response = await nocodbService.create( const response = await nocodbService.create(

View File

@ -38,6 +38,7 @@
<a href="#start-location" class="active">Start Location</a> <a href="#start-location" class="active">Start Location</a>
<a href="#walk-sheet">Walk Sheet</a> <a href="#walk-sheet">Walk Sheet</a>
<a href="#shifts">Shifts</a> <a href="#shifts">Shifts</a>
<a href="#users">User Management</a>
</nav> </nav>
</div> </div>
@ -239,6 +240,71 @@
</div> </div>
</div> </div>
</section> </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>
</div> </div>

View File

@ -564,90 +564,297 @@
color: var(--warning-color); color: var(--warning-color);
} }
/* Responsive - Scale down for smaller screens */ /* User Management Styles */
@media (max-width: 1400px) { .users-admin-container {
.walk-sheet-preview .walk-sheet-page { display: grid;
transform: scale(0.85); grid-template-columns: 1fr 2fr;
transform-origin: top center; gap: 30px;
} margin-top: 20px;
.walk-sheet-preview {
min-height: 850px;
}
} }
/* Mobile/Small Screen Layout - Stack config above preview */ .user-form,
@media (max-width: 1200px) { .users-list {
.walk-sheet-container { background: white;
display: flex !important; /* Change from grid to flex */ border-radius: var(--border-radius);
flex-direction: column !important; /* Stack vertically */ 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; gap: 20px;
} }
.walk-sheet-config { .users-table {
order: 1 !important; /* Config first */ font-size: 12px;
margin-bottom: 20px;
} }
.walk-sheet-preview { .users-table th,
order: 2 !important; /* Preview second */ .users-table td {
padding: 20px; padding: 8px;
min-height: auto;
max-width: 100vw;
overflow-x: auto; /* Allow horizontal scroll if needed */
display: flex;
justify-content: center; /* Center the page */
} }
.walk-sheet-preview .walk-sheet-page { .user-actions {
transform: scale(0.75); flex-direction: column;
transform-origin: top center; gap: 4px;
margin-bottom: -200px;
max-width: 100%; /* Prevent overflow */
} }
}
.user-actions .btn {
@media (max-width: 1000px) { font-size: 11px;
.walk-sheet-preview .walk-sheet-page { padding: 4px 8px;
transform: scale(0.5);
margin-bottom: -400px;
} }
}
.user-form .form-actions {
@media (max-width: 768px) { flex-direction: column;
}
/* Admin container mobile layout */
.admin-container { .admin-container {
flex-direction: column; flex-direction: column;
} }
.admin-sidebar { .admin-sidebar {
width: 100%; width: 100%;
border-right: none; border-right: none;
border-bottom: 1px solid #e0e0e0; 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 { .header .header-actions {
display: flex !important; display: flex !important;
gap: 10px; gap: 10px;
flex-wrap: wrap;
} }
.header .header-actions .btn { .header .header-actions .btn {
padding: 6px 10px; padding: 6px 10px;
font-size: 13px; font-size: 13px;
} }
.admin-info { .admin-info {
font-size: 12px; font-size: 12px;
} }
.admin-map-container { .admin-map-container {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 15px;
} }
.admin-map { .admin-map {
height: 220px; height: 250px;
} }
.admin-content { .admin-content {
padding: 8px; padding: 15px;
} }
.admin-section { .admin-section {
padding: 10px; padding: 15px;
} }
.form-row { .form-row {
grid-template-columns: 1fr; 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 { .walk-sheet-preview {
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
@ -673,20 +880,124 @@
left: 50%; left: 50%;
margin-left: -408px; /* Half of 816px to center it */ margin-left: -408px; /* Half of 816px to center it */
} }
}
/* Status container mobile */
/* Even smaller screens */ .status-container {
@media (max-width: 480px) { top: 10px;
.walk-sheet-preview { right: 10px;
padding: 5px; left: 10px;
width: 100%; 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: scale(0.25);
transform-origin: center top;
margin-bottom: -750px; margin-bottom: -750px;
left: 50%; margin-left: auto;
margin-left: -408px; /* Keep centered */ 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; 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> <label for="edit-sign-size">Sign Size</label>
<select id="edit-sign-size" name="Sign Size"> <select id="edit-sign-size" name="Sign Size">
<option value="">-- Select --</option> <option value="">-- Select --</option>
<option value="Small">Small</option> <option value="Regular">Regular</option>
<option value="Medium">Medium</option>
<option value="Large">Large</option> <option value="Large">Large</option>
<option value="Unsure">Unsure</option>
</select> </select>
</div> </div>
</div> </div>
@ -299,9 +299,9 @@
<label for="sign-size">Sign Size</label> <label for="sign-size">Sign Size</label>
<select id="sign-size" name="Sign Size"> <select id="sign-size" name="Sign Size">
<option value="">-- Select --</option> <option value="">-- Select --</option>
<option value="Small">Small</option> <option value="Regular">Regular</option>
<option value="Medium">Medium</option>
<option value="Large">Large</option> <option value="Large">Large</option>
<option value="Unsure">Unsure</option>
</select> </select>
</div> </div>
</div> </div>

View File

@ -260,6 +260,18 @@ function setupEventListeners() {
if (clearShiftBtn) { if (clearShiftBtn) {
clearShiftBtn.addEventListener('click', clearShiftForm); 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 // Setup navigation between admin sections
@ -297,6 +309,12 @@ function setupNavigation() {
loadAdminShifts(); 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 switching to walk sheet section, load config
if (targetId === 'walk-sheet') { if (targetId === 'walk-sheet') {
loadWalkSheetConfig().then((success) => { loadWalkSheetConfig().then((success) => {
@ -537,9 +555,9 @@ function generateWalkSheetPreview() {
<div class="ws-form-group"> <div class="ws-form-group">
<label class="ws-form-label">Sign Size</label> <label class="ws-form-label">Sign Size</label>
<div class="ws-form-field circles"> <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">R</span></span>
<span class="ws-circle-option"><span class="ws-circle">M</span></span>
<span class="ws-circle-option"><span class="ws-circle">L</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> </div>
<div class="ws-form-group"> <div class="ws-form-group">
@ -955,62 +973,6 @@ function displayAdminShifts(shifts) {
setupShiftActionListeners(); 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 // Fix the setupShiftActionListeners function
function setupShiftActionListeners() { function setupShiftActionListeners() {
const list = document.getElementById('admin-shifts-list'); const list = document.getElementById('admin-shifts-list');
@ -1112,101 +1074,181 @@ function clearShiftForm() {
} }
} }
// Update setupEventListeners to include shift form and clear button // User Management Functions
function setupEventListeners() { async function loadUsers() {
// Use current view button const loadingEl = document.getElementById('users-loading');
const useCurrentViewBtn = document.getElementById('use-current-view'); const emptyEl = document.getElementById('users-empty');
if (useCurrentViewBtn) { const tableBody = document.getElementById('users-table-body');
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');
});
}
// Save button if (loadingEl) loadingEl.style.display = 'block';
const saveLocationBtn = document.getElementById('save-start-location'); if (emptyEl) emptyEl.style.display = 'none';
if (saveLocationBtn) { if (tableBody) tableBody.innerHTML = '';
saveLocationBtn.addEventListener('click', saveStartLocation);
}
// Coordinate input changes try {
const startLatInput = document.getElementById('start-lat'); const response = await fetch('/api/users');
const startLngInput = document.getElementById('start-lng'); const data = await response.json();
const startZoomInput = document.getElementById('start-zoom');
if (loadingEl) loadingEl.style.display = 'none';
if (startLatInput) startLatInput.addEventListener('change', updateMapFromInputs);
if (startLngInput) startLngInput.addEventListener('change', updateMapFromInputs); if (data.success && data.users) {
if (startZoomInput) startZoomInput.addEventListener('change', updateMapFromInputs); displayUsers(data.users);
} else {
// Walk Sheet buttons throw new Error(data.error || 'Failed to load users');
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));
} }
});
} catch (error) {
// Add URL change listeners to detect when QR codes need regeneration console.error('Error loading users:', error);
for (let i = 1; i <= 3; i++) { if (loadingEl) loadingEl.style.display = 'none';
const urlInput = document.getElementById(`qr-code-${i}-url`); if (emptyEl) {
if (urlInput) { emptyEl.textContent = 'Failed to load users';
let previousUrl = urlInput.value; emptyEl.style.display = 'block';
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();
}
});
} }
} showStatus('Failed to load users', 'error');
// 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);
} }
} }
// Add the missing clearShiftForm function function displayUsers(users) {
function clearShiftForm() { const tableBody = document.getElementById('users-table-body');
const form = document.getElementById('shift-form'); const emptyEl = document.getElementById('users-empty');
if (form) {
form.reset(); if (!tableBody) return;
showStatus('Form cleared', 'info');
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 router = express.Router();
const settingsController = require('../controllers/settingsController'); 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 // Start location management
router.get('/start-location', settingsController.getStartLocation); router.get('/start-location', settingsController.getStartLocation);
router.post('/start-location', settingsController.updateStartLocation); 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; module.exports = router;

View File

@ -102,6 +102,11 @@ module.exports = (app) => {
res.sendFile(path.join(__dirname, '../public', 'shifts.html')); 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 // Catch all - redirect to login
app.get('*', (req, res) => { app.get('*', (req, res) => {
res.redirect('/login.html'); 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) - `unit_number` (SingleLineText)
- `support_level` (SingleSelect with colors: 1=Green, 2=Yellow, 3=Orange, 4=Red) - `support_level` (SingleSelect with colors: 1=Green, 2=Yellow, 3=Orange, 4=Red)
- `sign` (Checkbox) - `sign` (Checkbox)
- `sign_size` (SingleSelect: Small, Medium, Large) - `sign_size` (SingleSelect: Regular, Large, Unsure)
- `notes` (LongText) - `notes` (LongText)
- `address` (SingleLineText instead of LongText) - `address` (SingleLineText instead of LongText)

View File

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

View File

@ -92,7 +92,7 @@ Winston logger configuration for backend logging.
# app/public/admin.html # 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 # 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. 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 # 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 # 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) - 3 = Low Support (Orange)
- 4 = No Support (Red) - 4 = No Support (Red)
- `Sign` (Checkbox): Has campaign sign - `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 - `Notes` (Long Text): Additional details and comments
#### 2. Login Table #### 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: 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 ## 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>3 = Low Support (Orange)</li>
<li>4 = No Support (Red)</li> <li>4 = No Support (Red)</li>
<li><code>Sign</code> (Checkbox): Has campaign sign</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> <li><code>Notes</code> (Long Text): Additional details and comments</li>
</ul> </ul>
<h4 id="2-login-table">2. Login Table<a class="headerlink" href="#2-login-table" title="Permanent link">&para;</a></h4> <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>4 - No Support (Red)</li>
<li><code>Address</code> (Text): Full street address</li> <li><code>Address</code> (Text): Full street address</li>
<li><code>Sign</code> (Checkbox): Has campaign sign (true/false)</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> <li><code>Geo-Location</code> (Text): Formatted as "latitude;longitude"</li>
</ul> </ul>
<h2 id="api-endpoints">API Endpoints<a class="headerlink" href="#api-endpoints" title="Permanent link">&para;</a></h2> <h2 id="api-endpoints">API Endpoints<a class="headerlink" href="#api-endpoints" title="Permanent link">&para;</a></h2>