350 lines
15 KiB
HTML
350 lines
15 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||
<title>Register — {{ branding.app_name }}</title>
|
||
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style>
|
||
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
|
||
<style>
|
||
:root{
|
||
--bg:#f0f4f8;--surface:#ffffff;--border:#e2e8f0;--border2:#cbd5e1;
|
||
--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;
|
||
--success:#059669;--success-bg:#ecfdf5;--success-border:#a7f3d0;
|
||
--warning:#d97706;--warning-bg:#fffbeb;
|
||
--danger:#dc2626;--danger-bg:#fef2f2;
|
||
}
|
||
*{box-sizing:border-box;margin:0;padding:0;}
|
||
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;}
|
||
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
|
||
.card{background:#fff;border:1px solid var(--border);border-radius:16px;padding:40px;width:100%;max-width:500px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
|
||
.brand{text-align:center;margin-bottom:28px;}
|
||
.logo{width:46px;height:46px;background:var(--accent);border-radius:11px;display:flex;align-items:center;justify-content:center;font-family:'Space Mono',monospace;font-weight:700;font-size:15px;color:#fff;margin:0 auto 10px;box-shadow:0 2px 10px rgba(37,99,235,.3);}
|
||
h1{font-size:20px;font-weight:700;color:var(--text);}
|
||
.subtitle{font-size:13px;color:var(--muted);margin-top:4px;}
|
||
.row{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
|
||
.form-group{margin-bottom:16px;}
|
||
label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--text2);text-transform:uppercase;margin-bottom:6px;}
|
||
input,select{width:100%;background:#fff;border:1px solid var(--border);color:var(--text);border-radius:8px;padding:10px 13px;font-size:14px;font-family:inherit;transition:border-color .15s,box-shadow .15s;box-shadow:0 1px 3px rgba(0,0,0,.05);}
|
||
input:focus,select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
|
||
input::placeholder{color:#94a3b8;}
|
||
input.valid{border-color:var(--success);box-shadow:0 0 0 3px rgba(5,150,105,.08);}
|
||
input.invalid{border-color:var(--danger);box-shadow:0 0 0 3px rgba(220,38,38,.08);}
|
||
|
||
/* ── Password input wrapper (for show/hide toggle) ── */
|
||
.pw-wrap{position:relative;}
|
||
.pw-wrap input{padding-right:42px;}
|
||
.pw-toggle{
|
||
position:absolute;right:12px;top:50%;transform:translateY(-50%);
|
||
background:none;border:none;color:var(--muted);cursor:pointer;
|
||
font-size:16px;padding:2px;line-height:1;
|
||
transition:color .15s;
|
||
}
|
||
.pw-toggle:hover{color:var(--accent);}
|
||
|
||
/* ── Strength bar ── */
|
||
.strength-wrap{margin-top:8px;}
|
||
.strength-bar-track{
|
||
height:4px;background:var(--border);border-radius:2px;
|
||
overflow:hidden;margin-bottom:6px;
|
||
}
|
||
.strength-bar-fill{
|
||
height:100%;border-radius:2px;width:0%;
|
||
transition:width .3s ease,background .3s ease;
|
||
}
|
||
.strength-label{
|
||
font-size:11px;font-weight:600;letter-spacing:.3px;
|
||
font-family:'Space Mono',monospace;
|
||
transition:color .3s;
|
||
display:flex;align-items:center;gap:5px;
|
||
}
|
||
|
||
/* ── Rule checklist ── */
|
||
.rules{
|
||
margin-top:8px;display:grid;grid-template-columns:1fr 1fr;gap:3px 12px;
|
||
background:var(--bg);border:1px solid var(--border);border-radius:8px;
|
||
padding:10px 12px;
|
||
}
|
||
.rule{
|
||
display:flex;align-items:center;gap:5px;
|
||
font-size:11.5px;color:var(--muted);
|
||
transition:color .2s;
|
||
}
|
||
.rule .ri{
|
||
font-size:13px;width:14px;text-align:center;
|
||
transition:color .2s, transform .15s;
|
||
}
|
||
.rule.met{color:var(--success);}
|
||
.rule.met .ri{color:var(--success);transform:scale(1.1);}
|
||
.rule.unmet .ri{color:var(--border2);}
|
||
|
||
/* ── Confirm match indicator ── */
|
||
.match-msg{
|
||
font-size:11.5px;margin-top:6px;display:flex;align-items:center;gap:5px;
|
||
font-family:'Space Mono',monospace;font-weight:600;letter-spacing:.2px;
|
||
min-height:18px;transition:color .2s;
|
||
}
|
||
.match-msg.ok{color:var(--success);}
|
||
.match-msg.fail{color:var(--danger);}
|
||
|
||
/* ── Submit ── */
|
||
.btn{width:100%;background:var(--accent);border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;margin-top:4px;box-shadow:0 2px 6px rgba(37,99,235,.3);}
|
||
.btn:hover{background:var(--accent-h);}
|
||
.btn:disabled{background:#93c5fd;cursor:not-allowed;box-shadow:none;}
|
||
.links{text-align:center;margin-top:18px;font-size:13px;color:var(--muted);}
|
||
.links a{color:var(--accent);}
|
||
.alert{border-radius:8px;padding:10px 13px;font-size:13px;margin-bottom:16px;}
|
||
.alert-danger{background:#fef2f2;color:#dc2626;border:1px solid #fecaca;}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="bg-grid"></div>
|
||
<div class="card">
|
||
<div class="brand">
|
||
{% if branding.logo_stored_name %}
|
||
<img src="{{ url_for('auth.serve_logo', filename=branding.logo_stored_name) }}"
|
||
alt="{{ branding.app_name }}"
|
||
style="width:46px;height:46px;border-radius:11px;object-fit:contain;margin:0 auto 10px;display:block;background:var(--accent);padding:3px;box-shadow:0 2px 10px rgba(0,0,0,.15);"/>
|
||
{% else %}
|
||
<div class="logo">{{ branding.logo_initials[:2] }}</div>
|
||
{% endif %}
|
||
<h1>Create Account</h1>
|
||
<p class="subtitle">
|
||
Join {% if branding.company_name %}{{ branding.company_name }}{% else %}{{ branding.app_name }}{% endif %} to submit IT support requests
|
||
</p>
|
||
</div>
|
||
|
||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||
{% for cat, msg in messages %}
|
||
<div class="alert alert-{{ cat }}">{{ msg }}</div>
|
||
{% endfor %}
|
||
{% endwith %}
|
||
|
||
<form method="POST" id="reg-form">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||
<div class="row">
|
||
<div class="form-group">
|
||
<label>Full Name *</label>
|
||
<input type="text" name="full_name" required placeholder="Jane Smith"/>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Username *</label>
|
||
<input type="text" name="username" required placeholder="jsmith"/>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Work Email *</label>
|
||
<input type="email" name="email" required placeholder="jane.smith@company.com"/>
|
||
</div>
|
||
<div class="row">
|
||
<div class="form-group">
|
||
<label>Department</label>
|
||
<input type="text" name="department" placeholder="Finance"/>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Phone</label>
|
||
<input type="text" name="phone" placeholder="+1 555 000 0000"/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Password with strength meter ── -->
|
||
<div class="form-group">
|
||
<label>Password *</label>
|
||
<div class="pw-wrap">
|
||
<input type="password" name="password" id="pw" required
|
||
placeholder="Min. 8 characters"
|
||
autocomplete="new-password"
|
||
oninput="onPasswordInput()"/>
|
||
<button type="button" class="pw-toggle" id="pw-toggle"
|
||
onclick="toggleVis('pw','pw-toggle')"
|
||
title="Show / hide password"
|
||
tabindex="-1">
|
||
<i class="bi bi-eye"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Strength bar -->
|
||
<div class="strength-wrap" id="strength-wrap" style="display:none;">
|
||
<div class="strength-bar-track">
|
||
<div class="strength-bar-fill" id="strength-bar"></div>
|
||
</div>
|
||
<div class="strength-label" id="strength-label"></div>
|
||
</div>
|
||
|
||
<!-- Rule checklist — mirrors server-side validate_password() rules -->
|
||
<div class="rules" id="rules-box" style="display:none;">
|
||
<div class="rule unmet" id="rule-len">
|
||
<span class="ri"><i class="bi bi-circle"></i></span>
|
||
<span>8+ characters</span>
|
||
</div>
|
||
<div class="rule unmet" id="rule-upper">
|
||
<span class="ri"><i class="bi bi-circle"></i></span>
|
||
<span>Uppercase letter</span>
|
||
</div>
|
||
<div class="rule unmet" id="rule-digit">
|
||
<span class="ri"><i class="bi bi-circle"></i></span>
|
||
<span>Number</span>
|
||
</div>
|
||
<div class="rule unmet" id="rule-special">
|
||
<span class="ri"><i class="bi bi-circle"></i></span>
|
||
<span>Special character</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Confirm password with match indicator ── -->
|
||
<div class="form-group">
|
||
<label>Confirm Password *</label>
|
||
<div class="pw-wrap">
|
||
<input type="password" name="confirm_password" id="pw2" required
|
||
placeholder="Repeat password"
|
||
autocomplete="new-password"
|
||
oninput="onConfirmInput()"/>
|
||
<button type="button" class="pw-toggle" id="pw2-toggle"
|
||
onclick="toggleVis('pw2','pw2-toggle')"
|
||
title="Show / hide password"
|
||
tabindex="-1">
|
||
<i class="bi bi-eye"></i>
|
||
</button>
|
||
</div>
|
||
<div class="match-msg" id="match-msg"></div>
|
||
</div>
|
||
|
||
<button type="submit" class="btn" id="submit-btn">
|
||
<i class="bi bi-person-plus" style="margin-right:6px;"></i>Create Account
|
||
</button>
|
||
</form>
|
||
<div class="links">Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a></div>
|
||
</div>
|
||
|
||
<script>
|
||
// ── Mirrors server-side validate_password() rules in validation_service.py ──
|
||
// Any change to the server rules must be reflected here too.
|
||
const RULES = {
|
||
len : pw => pw.length >= 8,
|
||
upper : pw => /[A-Z]/.test(pw),
|
||
digit : pw => /\d/.test(pw),
|
||
special: pw => /[!@#$%^&*()\-_=+\[\]{};:'",.<>?/\\|`~]/.test(pw),
|
||
};
|
||
|
||
// Strength scoring: each rule = 1 point; bonus point for length >= 16
|
||
function scorePassword(pw) {
|
||
let score = 0;
|
||
if (RULES.len(pw)) score++;
|
||
if (RULES.upper(pw)) score++;
|
||
if (RULES.digit(pw)) score++;
|
||
if (RULES.special(pw)) score++;
|
||
if (pw.length >= 16) score++; // bonus for extra length
|
||
return score; // 0–5
|
||
}
|
||
|
||
const LEVELS = [
|
||
{ label: 'Very Weak', color: '#ef4444', pct: 12 },
|
||
{ label: 'Weak', color: '#f97316', pct: 30 },
|
||
{ label: 'Fair', color: '#eab308', pct: 52 },
|
||
{ label: 'Good', color: '#22c55e', pct: 76 },
|
||
{ label: 'Strong', color: '#059669', pct: 100 },
|
||
];
|
||
|
||
function setRule(id, met) {
|
||
const el = document.getElementById('rule-' + id);
|
||
if (!el) return;
|
||
el.className = 'rule ' + (met ? 'met' : 'unmet');
|
||
el.querySelector('.ri').innerHTML = met
|
||
? '<i class="bi bi-check-circle-fill"></i>'
|
||
: '<i class="bi bi-circle"></i>';
|
||
}
|
||
|
||
function onPasswordInput() {
|
||
const pw = document.getElementById('pw').value;
|
||
const wrap = document.getElementById('strength-wrap');
|
||
const rules = document.getElementById('rules-box');
|
||
const bar = document.getElementById('strength-bar');
|
||
const lbl = document.getElementById('strength-label');
|
||
|
||
if (pw.length === 0) {
|
||
wrap.style.display = 'none';
|
||
rules.style.display = 'none';
|
||
document.getElementById('pw').classList.remove('valid','invalid');
|
||
updateSubmitState();
|
||
onConfirmInput();
|
||
return;
|
||
}
|
||
|
||
wrap.style.display = 'block';
|
||
rules.style.display = 'grid';
|
||
|
||
// Update rule checklist
|
||
setRule('len', RULES.len(pw));
|
||
setRule('upper', RULES.upper(pw));
|
||
setRule('digit', RULES.digit(pw));
|
||
setRule('special', RULES.special(pw));
|
||
|
||
// Update strength bar
|
||
const score = scorePassword(pw);
|
||
const lvl = LEVELS[Math.max(0, score - 1)] || LEVELS[0];
|
||
bar.style.width = lvl.pct + '%';
|
||
bar.style.background = lvl.color;
|
||
lbl.style.color = lvl.color;
|
||
lbl.innerHTML = `<i class="bi bi-shield${score >= 4 ? '-fill' : score >= 2 ? '-half' : ''}"></i> ${lvl.label}`;
|
||
|
||
// Border feedback on password field
|
||
const allMet = RULES.len(pw) && RULES.upper(pw) && RULES.digit(pw) && RULES.special(pw);
|
||
document.getElementById('pw').classList.toggle('valid', allMet);
|
||
document.getElementById('pw').classList.toggle('invalid', !allMet);
|
||
|
||
onConfirmInput();
|
||
updateSubmitState();
|
||
}
|
||
|
||
function onConfirmInput() {
|
||
const pw = document.getElementById('pw').value;
|
||
const pw2 = document.getElementById('pw2').value;
|
||
const msg = document.getElementById('match-msg');
|
||
const el2 = document.getElementById('pw2');
|
||
|
||
if (pw2.length === 0) {
|
||
msg.textContent = '';
|
||
msg.className = 'match-msg';
|
||
el2.classList.remove('valid','invalid');
|
||
updateSubmitState();
|
||
return;
|
||
}
|
||
|
||
if (pw === pw2) {
|
||
msg.innerHTML = '<i class="bi bi-check-circle-fill"></i> Passwords match';
|
||
msg.className = 'match-msg ok';
|
||
el2.classList.add('valid');
|
||
el2.classList.remove('invalid');
|
||
} else {
|
||
msg.innerHTML = '<i class="bi bi-x-circle-fill"></i> Passwords do not match';
|
||
msg.className = 'match-msg fail';
|
||
el2.classList.add('invalid');
|
||
el2.classList.remove('valid');
|
||
}
|
||
updateSubmitState();
|
||
}
|
||
|
||
function updateSubmitState() {
|
||
const pw = document.getElementById('pw').value;
|
||
const pw2 = document.getElementById('pw2').value;
|
||
const btn = document.getElementById('submit-btn');
|
||
const allMet = RULES.len(pw) && RULES.upper(pw) && RULES.digit(pw) && RULES.special(pw);
|
||
const match = pw === pw2 && pw2.length > 0;
|
||
// Disable only when user has started typing in either field and criteria
|
||
// are not met — never disable before they've interacted with the fields.
|
||
const hasStarted = pw.length > 0 || pw2.length > 0;
|
||
btn.disabled = hasStarted && !(allMet && match);
|
||
}
|
||
|
||
function toggleVis(inputId, btnId) {
|
||
const inp = document.getElementById(inputId);
|
||
const btn = document.getElementById(btnId);
|
||
const isPassword = inp.type === 'password';
|
||
inp.type = isPassword ? 'text' : 'password';
|
||
btn.querySelector('i').className = isPassword ? 'bi bi-eye-slash' : 'bi bi-eye';
|
||
btn.title = isPassword ? 'Hide password' : 'Show password';
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |