Apr 2 2026: separate management of internal user and customer

This commit is contained in:
2026-04-02 17:15:26 -04:00
parent 51d8088b25
commit 4128d14286
8 changed files with 550 additions and 16 deletions
+76
View File
@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}Create Customer Account{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="bi bi-person-plus-fill me-2"></i>Create Customer Account
</h4>
</div>
<div class="card-body">
<p class="text-muted mb-4" style="font-size:.9rem;">
Enter the customer's name and email address. An invitation email will be
sent automatically with a secure link to set their password.
The account will be activated once they complete that step.
</p>
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.full_name.label(class="form-label fw-semibold") }}
{{ form.full_name(class="form-control" + (" is-invalid" if form.full_name.errors else ""),
placeholder="e.g. Jane Smith", autofocus=true) }}
{% for error in form.full_name.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-4">
{{ form.email.label(class="form-label fw-semibold") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
placeholder="jane@example.com") }}
{% for error in form.email.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div class="form-text">
<i class="bi bi-envelope me-1"></i>
An invitation email with a password setup link will be sent to this address.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Create &amp; Send Invitation
</button>
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>Cancel
</a>
</div>
</form>
</div>
</div>
<div class="card mt-3 border-0 bg-light">
<div class="card-body py-2 px-3">
<p class="mb-1" style="font-size:.8rem;">
<i class="bi bi-info-circle me-1 text-primary"></i>
<strong>What happens next:</strong>
</p>
<ol class="mb-0 ps-3" style="font-size:.8rem; color:#555;">
<li>A username is automatically generated from the email address.</li>
<li>An invitation email is sent to the customer with a secure 72-hour link.</li>
<li>The customer clicks the link and sets their own password.</li>
<li>The account becomes fully active and they can log in immediately.</li>
</ol>
</div>
</div>
</div>
</div>
{% endblock %}
+23 -2
View File
@@ -5,14 +5,14 @@
<div class="row mb-4 align-items-center">
<div class="col">
<h2>
<i class="bi bi-person-badge"></i> {{ customer.username }}
<i class="bi bi-person-badge"></i> {{ customer.display_name }}
{% if not customer.active %}
<span class="badge bg-secondary ms-2 fs-6">Disabled</span>
{% else %}
<span class="badge bg-success ms-2 fs-6">Active</span>
{% endif %}
</h2>
<p class="text-muted mb-0 small">{{ customer.email }}</p>
<p class="text-muted mb-0 small">{{ customer.email }}{% if customer.full_name %} · @{{ customer.username }}{% endif %}</p>
</div>
<div class="col-auto d-flex gap-2">
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}"
@@ -46,6 +46,8 @@
</div>
<div class="card-body">
<dl class="row mb-0 small">
<dt class="col-5 text-muted">Full Name</dt>
<dd class="col-7">{{ customer.full_name or '—' }}</dd>
<dt class="col-5 text-muted">Username</dt>
<dd class="col-7">{{ customer.username }}</dd>
<dt class="col-5 text-muted">Email</dt>
@@ -56,6 +58,14 @@
{{ 'Active' if customer.active else 'Disabled' }}
</span>
</dd>
<dt class="col-5 text-muted">Password</dt>
<dd class="col-7">
{% if customer.password_set %}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Set</span>
{% else %}
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>Pending setup</span>
{% endif %}
</dd>
<dt class="col-5 text-muted">Created</dt>
<dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd>
<dt class="col-5 text-muted">Assignments</dt>
@@ -63,6 +73,17 @@
<dt class="col-5 text-muted">Facilities</dt>
<dd class="col-7">{{ facilities|length }}</dd>
</dl>
{% if not customer.password_set %}
<hr class="my-3">
<form method="POST"
action="{{ url_for('customers.resend_invite', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-primary btn-sm w-100"
onclick="return confirm('Resend invitation email to {{ customer.email }}?')">
<i class="bi bi-send me-1"></i>Resend Invitation Email
</button>
</form>
{% endif %}
</div>
</div>
+163
View File
@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set Your Password — Janitorial QC</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background: #eef0f4; font-family: 'Segoe UI', Arial, sans-serif; }
.setup-card {
max-width: 460px; margin: 80px auto;
border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.1);
}
.setup-header {
background: #1a1d23; color: #fff;
border-radius: 12px 12px 0 0;
padding: 1.5rem 1.75rem 1.25rem;
}
.setup-header h4 { margin: 0; font-weight: 600; }
.setup-header p { color: #94a3b8; font-size: .85rem; margin: .35rem 0 0; }
.setup-body { background: #fff; border-radius: 0 0 12px 12px; padding: 1.75rem; }
.req-item { font-size: .8rem; color: #64748b; }
.req-item.met { color: #16a34a; }
.strength-bar { height: 4px; border-radius: 2px; transition: all .3s; }
</style>
</head>
<body>
<div class="setup-card">
<div class="setup-header">
<h4><i class="bi bi-shield-lock me-2"></i>Set Your Password</h4>
<p>Welcome, {{ user.display_name }}. Choose a secure password to activate your account.</p>
</div>
<div class="setup-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ 'danger' if cat == 'danger' else 'warning' if cat == 'warning' else 'success' }}
alert-dismissible fade show py-2 mb-3" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
<form method="POST" id="setPasswordForm" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="password" class="form-label fw-semibold">New Password</label>
<input type="password"
id="password"
name="password"
class="form-control {{ 'is-invalid' if form.password.errors else '' }}"
autocomplete="new-password"
autofocus>
{% for error in form.password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{# Strength bar #}
<div class="mt-2 mb-1">
<div class="bg-light rounded" style="height:4px;">
<div id="strengthBar" class="strength-bar bg-secondary" style="width:0%;"></div>
</div>
</div>
{# Requirements checklist #}
<div class="d-flex flex-wrap gap-3 mt-2">
<span class="req-item" id="req-len">
<i class="bi bi-circle me-1"></i>8+ characters
</span>
<span class="req-item" id="req-upper">
<i class="bi bi-circle me-1"></i>Uppercase letter
</span>
<span class="req-item" id="req-num">
<i class="bi bi-circle me-1"></i>Number
</span>
</div>
</div>
<div class="mb-4">
<label for="confirm_password" class="form-label fw-semibold">Confirm Password</label>
<input type="password"
id="confirm_password"
name="confirm_password"
class="form-control {{ 'is-invalid' if form.confirm_password.errors else '' }}"
autocomplete="new-password">
{% for error in form.confirm_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div id="matchFeedback" class="form-text" style="display:none;"></div>
</div>
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
<i class="bi bi-check2-circle me-1"></i>Set Password &amp; Log In
</button>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function () {
'use strict';
var pwEl = document.getElementById('password');
var cfEl = document.getElementById('confirm_password');
var bar = document.getElementById('strengthBar');
var reqLen = document.getElementById('req-len');
var reqUpper = document.getElementById('req-upper');
var reqNum = document.getElementById('req-num');
var matchFb = document.getElementById('matchFeedback');
var submitBtn = document.getElementById('submitBtn');
function markReq(el, met) {
el.className = 'req-item' + (met ? ' met' : '');
el.querySelector('i').className = (met ? 'bi bi-check-circle-fill' : 'bi bi-circle') + ' me-1';
}
function updateStrength(pw) {
var score = 0;
var hasLen = pw.length >= 8;
var hasUpper = /[A-Z]/.test(pw);
var hasNum = /[0-9]/.test(pw);
if (hasLen) score++;
if (hasUpper) score++;
if (hasNum) score++;
if (pw.length >= 12) score++;
markReq(reqLen, hasLen);
markReq(reqUpper, hasUpper);
markReq(reqNum, hasNum);
var pct = score * 25;
var color = score <= 1 ? 'danger' : score === 2 ? 'warning' : score === 3 ? 'info' : 'success';
bar.style.width = pct + '%';
bar.className = 'strength-bar bg-' + color;
}
function checkMatch() {
if (!cfEl.value) { matchFb.style.display = 'none'; return; }
matchFb.style.display = '';
if (pwEl.value === cfEl.value) {
matchFb.textContent = '✓ Passwords match';
matchFb.style.color = '#16a34a';
} else {
matchFb.textContent = '✗ Passwords do not match';
matchFb.style.color = '#dc2626';
}
}
pwEl.addEventListener('input', function () {
updateStrength(this.value);
checkMatch();
});
cfEl.addEventListener('input', checkMatch);
})();
</script>
</body>
</html>