source: Klonkt/src/views/pages/account.ejs@ bf61be7

main
Last change on this file since bf61be7 was 9e27d64, checked in by roboburr <roboburr@…>, 3 months ago

auth: password admin + per-instance Google for listeners (no broker)

Robin's choice: every self-hoster has their own password admin account,
and can optionally let listeners log in to comment using their OWN Google
client. No central broker (that would tie every customer site to Robin's
Google Cloud -> systemic risk on abuse).

  • Admin = username/password (bcrypt). First-time setup via /auth/register (only when there are 0 users); closed afterwards. No public registration.
  • Forgot password: /auth/reset-request -> email (if SMTP configured) with reset link; CLI break-glass npm run reset-admin always works (no email needed).
  • Change password (logged in) restored in /account.
  • Google = per-instance own credentials, OPTIONAL, listeners only -> always role member, never admin (god/admin email is rejected; google_sub mismatch too).
  • config/google.js back to direct Google OAuth; config/mailer.js new (nodemailer).
  • jose removed from deps; nodemailer added.

Security review (workflow) incorporated:

  • Reset token no longer in production logs (dev only).
  • Reset link from PUBLIC_BASE_URL instead of X-Forwarded-Host (host poisoning).
  • Reset tokens stored SHA-256-hashed in the DB.
  • Same-origin check on all state-modifying POSTs (CSRF layer on top of sameSite-lax).
  • Login always runs one bcrypt comparison (no timing enumeration).

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 9.9 KB
Line 
1<div class="container ax-page">
2
3 <header class="ax-header">
4 <a href="/" class="ax-back" aria-label="Terug naar home">←</a>
5 <div>
6 <h1>Account</h1>
7 <p class="ax-tagline">Profiel en avatar.</p>
8 </div>
9 </header>
10
11 <% if (success) { %><div class="ax-flash ax-flash-ok"><%= success %></div><% } %>
12 <% if (error) { %><div class="ax-flash ax-flash-err"><%= error %></div><% } %>
13
14 <%# ── PROFILE ────────────────────────────────────────────── %>
15 <section class="ax-card">
16 <div class="ax-card-title">Profiel</div>
17
18 <div class="ax-profile-id">
19 <div class="ax-profile-avatar">
20 <% if (account.avatar_url) { %>
21 <img src="<%= account.avatar_url %>" alt="">
22 <% } else { %>
23 <span><%= account.username.charAt(0).toUpperCase() %></span>
24 <% } %>
25 </div>
26 <div class="ax-profile-meta">
27 <div class="ax-profile-name"><%= account.username %></div>
28 <div class="ax-profile-role"><%= account.role %> · <%= account.email %></div>
29 <% if (account.created_at) { %>
30 <div class="ax-profile-joined">Lid sinds <%= formatDate(account.created_at) %></div>
31 <% } %>
32 </div>
33 </div>
34
35 <form action="/account/avatar" method="post" enctype="multipart/form-data" class="ax-form">
36 <label class="ax-field ax-file">
37 <span>Avatar uploaden <small>(jpg/png/webp/gif, max 5 MB)</small></span>
38 <span class="ax-file-control">
39 <span class="ax-btn ax-btn-secondary ax-file-btn">📷 Kies bestand</span>
40 <span class="ax-file-name" data-empty>Geen bestand gekozen</span>
41 <input type="file" name="avatar" accept="image/jpeg,image/png,image/webp,image/gif" required>
42 </span>
43 </label>
44 <div class="ax-form-actions">
45 <button type="submit" class="ax-btn ax-btn-primary">⬆ Uploaden</button>
46 <% if (account.avatar_url) { %>
47 <%# Separate <form> block kept inline since it submits to a different endpoint. %>
48 <button type="button" class="ax-btn" onclick="document.getElementById('avatar-remove-form').submit()">Avatar verwijderen</button>
49 <% } %>
50 </div>
51 </form>
52 <% if (account.avatar_url) { %>
53 <form action="/account/avatar/remove" method="post" id="avatar-remove-form" hidden></form>
54 <% } %>
55
56 <form action="/account/profile" method="post" class="ax-form ax-form-bio">
57 <label class="ax-field">
58 <span>Bio</span>
59 <textarea name="bio" rows="3" maxlength="500" placeholder="Een korte regel over jezelf"><%= account.bio || '' %></textarea>
60 </label>
61 <div class="ax-form-actions">
62 <button type="submit" class="ax-btn ax-btn-primary">💾 Bio opslaan</button>
63 </div>
64 </form>
65 </section>
66
67 <%# ── WACHTWOORD ────────────────────────────────────────── %>
68 <% if (hasPassword) { %>
69 <section class="ax-card">
70 <div class="ax-card-title">Wachtwoord wijzigen</div>
71 <form action="/account/password" method="post" class="ax-form">
72 <label class="ax-field">
73 <span>Huidig wachtwoord</span>
74 <input type="password" name="current" required autocomplete="current-password">
75 </label>
76 <div class="ax-row ax-row-2">
77 <label class="ax-field">
78 <span>Nieuw wachtwoord <small>(min 8 tekens)</small></span>
79 <input type="password" name="new_password" required minlength="8" autocomplete="new-password">
80 </label>
81 <label class="ax-field">
82 <span>Bevestig nieuw wachtwoord</span>
83 <input type="password" name="confirm" required minlength="8" autocomplete="new-password">
84 </label>
85 </div>
86 <div class="ax-form-actions">
87 <button type="submit" class="ax-btn ax-btn-primary">🔒 Wachtwoord wijzigen</button>
88 </div>
89 </form>
90 </section>
91 <% } else { %>
92 <section class="ax-card">
93 <div class="ax-card-title">Inloggen</div>
94 <p class="ax-tagline" style="margin:0">Je bent ingelogd via Google (<%= account.email %>). Er is geen apart wachtwoord.</p>
95 </section>
96 <% } %>
97
98</div>
99
100<style>
101.ax-page { max-width: 720px; margin: 1.5rem auto 4rem; padding: 0 1rem; }
102.ax-header { display: flex; align-items: flex-start; gap: 0.75rem; margin-bottom: 1.5rem; }
103.ax-header h1 { font-family: var(--font-display, serif); font-size: 1.75rem; margin: 0 0 0.25rem; }
104.ax-tagline { color: var(--ink-muted, var(--ink-soft)); margin: 0; font-size: 0.9rem; }
105.ax-back {
106 display: inline-flex; align-items: center; justify-content: center;
107 width: 40px; height: 40px;
108 border: 1px solid var(--rule); border-radius: 8px;
109 background: var(--paper); color: var(--ink); text-decoration: none;
110 font-size: 1.1rem; flex-shrink: 0; transition: border-color 120ms;
111}
112.ax-back:hover { border-color: var(--accent); }
113
114.ax-flash { padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
115.ax-flash-ok { background: rgba(40,160,90,.15); color: #2a9d5e; border: 1px solid rgba(40,160,90,.3); }
116.ax-flash-err { background: rgba(200,60,60,.15); color: #c33; border: 1px solid rgba(200,60,60,.3); }
117
118/* ─── Cards ────────────────────────────────────────────────────── */
119.ax-card {
120 background: var(--paper);
121 border: 1px solid var(--rule);
122 border-radius: 12px;
123 padding: 1.25rem;
124 margin-bottom: 1rem;
125}
126.ax-card-title {
127 font-family: var(--font-display, serif);
128 font-size: 1.15rem;
129 font-weight: 600;
130 margin-bottom: 1rem;
131}
132
133/* ─── Form fields ──────────────────────────────────────────────── */
134.ax-form { display: flex; flex-direction: column; gap: 0.85rem; }
135.ax-form-bio { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--rule); }
136.ax-row { display: grid; gap: 0.85rem; }
137.ax-row-2 { grid-template-columns: 1fr 1fr; }
138@media (max-width: 600px) { .ax-row-2 { grid-template-columns: 1fr; } }
139
140.ax-field { display: flex; flex-direction: column; gap: 0.35rem; min-width: 0; }
141.ax-field > span {
142 font-size: 0.8rem; font-weight: 600;
143 color: var(--ink-soft);
144 display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap;
145}
146.ax-field > span small { font-weight: 400; color: var(--ink-muted); font-size: 0.95em; }
147.ax-field input[type="text"],
148.ax-field input[type="email"],
149.ax-field input[type="password"],
150.ax-field input[type="url"],
151.ax-field select,
152.ax-field textarea {
153 width: 100%;
154 box-sizing: border-box;
155 padding: 0.6rem 0.75rem;
156 border: 1px solid var(--rule);
157 border-radius: 6px;
158 background: var(--paper-2);
159 color: var(--ink);
160 font-family: var(--font-ui, system-ui), sans-serif;
161 font-size: 0.95rem;
162 -webkit-appearance: none; appearance: none;
163 transition: border-color 120ms;
164}
165.ax-field textarea { resize: vertical; min-height: 70px; font-family: inherit; }
166.ax-field input:focus, .ax-field select:focus, .ax-field textarea:focus {
167 outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent);
168}
169
170/* ─── File upload ─────────────────────────────────────────────── */
171.ax-file-control {
172 display: flex; align-items: center; gap: 0.5rem;
173 flex-wrap: wrap;
174 position: relative;
175 min-height: 40px;
176}
177.ax-file-control input[type="file"] {
178 position: absolute; inset: 0;
179 opacity: 0; cursor: pointer;
180 z-index: 2;
181}
182.ax-file-btn { pointer-events: none; }
183.ax-file-name {
184 font-size: 0.85rem;
185 color: var(--ink);
186 word-break: break-all;
187 flex: 1; min-width: 0;
188}
189.ax-file-name[data-empty] { color: var(--ink-muted, var(--ink-soft)); }
190
191.ax-form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.25rem; }
192
193/* ─── Buttons ──────────────────────────────────────────────────── */
194.ax-btn {
195 display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
196 padding: 0.55rem 1rem;
197 border: 1px solid var(--rule); background: var(--paper-2);
198 color: var(--ink);
199 font-family: var(--font-ui, system-ui), sans-serif;
200 font-size: 0.9rem; font-weight: 500;
201 text-decoration: none; border-radius: 6px;
202 cursor: pointer; min-height: 40px;
203 transition: background 120ms, border-color 120ms;
204 -webkit-tap-highlight-color: transparent;
205}
206.ax-btn:hover { border-color: var(--accent); }
207.ax-btn-secondary { background: var(--paper-2); }
208.ax-btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
209.ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
210
211/* ─── Profile ID row ──────────────────────────────────────────── */
212.ax-profile-id {
213 display: flex; align-items: center; gap: 1rem;
214 padding-bottom: 1rem;
215 border-bottom: 1px solid var(--rule);
216 margin-bottom: 1rem;
217}
218.ax-profile-avatar {
219 width: 64px; height: 64px;
220 border-radius: 50%;
221 overflow: hidden;
222 background: var(--paper-2); /* neutral so transparent avatars blend */
223 color: var(--accent); /* accent only for the fallback initial */
224 display: inline-flex; align-items: center; justify-content: center;
225 font-family: var(--font-display, serif);
226 font-size: 1.6rem; font-weight: 600;
227 flex-shrink: 0;
228}
229.ax-profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
230.ax-profile-meta { min-width: 0; }
231.ax-profile-name { font-weight: 600; font-size: 1.1rem; }
232.ax-profile-role,
233.ax-profile-joined {
234 color: var(--ink-muted, var(--ink-soft));
235 font-size: 0.85rem;
236}
237
238@media (max-width: 480px) {
239 .ax-profile-id { flex-direction: column; align-items: flex-start; gap: 0.75rem; text-align: left; }
240}
241</style>
Note: See TracBrowser for help on using the repository browser.