Changeset c80e78b in Klonkt


Ignore:
Timestamp:
06/14/2026 04:39:57 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
ae924a2
Parents:
89d6536
Message:

auth: replace username/password with Google login

Listeners (and the owner) now log in via Google instead of a local
username/password account. This lowers the barrier to commenting and
removes the home-built password/registration system.

  • src/config/google.js: raw OAuth2 helpers (authorize/token/userinfo) via the built-in fetch, config-driven. Boot keeps working without credentials.
  • src/routes/auth.js: /auth/google + /auth/google/callback (find-or-create user on email, ADMIN_EMAIL -> god, set session). login/register/reset POST handlers removed; /login now shows the Google button.
  • database.js: idempotent column users.google_sub.
  • account.js + account.ejs: change-password removed.
  • auth-login.ejs / welcome.ejs: Google button instead of password form.
  • shared-styles.ejs: .btn-google styling.
  • .env.example: GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI + ADMIN_EMAIL.

Existing users are matched on email (owner retains their site).
DO NOT deploy to roboburr until the Google credentials are in .env, otherwise
the owner locks themselves out.

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

Files:
1 added
8 edited

Legend:

Unmodified
Added
Removed
  • .env.example

    r89d6536 rc80e78b  
    44DATABASE_PATH=./storage/database.sqlite
    55MEDIA_PATH=./storage/media
     6
     7# ── Google login (OAuth2) ──────────────────────────────────────────
     8# Inloggen gaat volledig via Google. Maak een OAuth-client aan in de
     9# Google Cloud Console (APIs & Services -> Credentials -> OAuth client ID,
     10# type "Web application") en vul hieronder in. Zonder deze waarden boot de
     11# app gewoon, maar toont de loginpagina "nog niet geconfigureerd".
     12#
     13# GOOGLE_REDIRECT_URI moet EXACT overeenkomen met een "Authorized redirect
     14# URI" in de Google-client, en is per site het eigen domein + /auth/google/callback.
     15GOOGLE_CLIENT_ID=
     16GOOGLE_CLIENT_SECRET=
     17GOOGLE_REDIRECT_URI=https://example.com/auth/google/callback
     18
     19# Het Google-e-mailadres dat owner/admin (god) is op deze instance.
     20# De eerste gebruiker die inlogt wordt sowieso god als de DB nog leeg is.
     21ADMIN_EMAIL=
  • src/config/database.js

    r89d6536 rc80e78b  
    3939  ensureColumn('users', 'reset_token', 'TEXT');
    4040  ensureColumn('users', 'reset_token_expires', 'DATETIME');
     41  // Google OAuth: koppel een Google-account aan een user (login via Google).
     42  ensureColumn('users', 'google_sub', 'TEXT');
    4143  // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
    4244  ensureColumn('sites', 'comments_moderation_mode', "TEXT DEFAULT 'trust'");
  • src/routes/account.js

    r89d6536 rc80e78b  
    1717import fs from 'fs';
    1818import { fileURLToPath } from 'url';
    19 import bcrypt from 'bcryptjs';
    2019import multer from 'multer';
    2120import { v4 as uuid } from 'uuid';
     
    8483// longer read or written.
    8584
    86 // ==================== CHANGE PASSWORD ====================
    87 router.post('/password', requireAuth, (req, res) => {
    88   const { current, new_password, confirm } = req.body;
    89   if (!current || !new_password || !confirm) {
    90     return res.redirect('/account?error=' + encodeURIComponent('All password fields required'));
    91   }
    92   if (new_password.length < 8) {
    93     return res.redirect('/account?error=' + encodeURIComponent('New password must be at least 8 characters'));
    94   }
    95   if (new_password !== confirm) {
    96     return res.redirect('/account?error=' + encodeURIComponent('New passwords do not match'));
    97   }
    98 
    99   const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
    100   if (!row || !bcrypt.compareSync(current, row.password_hash)) {
    101     return res.redirect('/account?error=' + encodeURIComponent('Current password is incorrect'));
    102   }
    103 
    104   const newHash = bcrypt.hashSync(new_password, 10);
    105   db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
    106     .run(newHash, req.session.user.id);
    107 
    108   res.redirect('/account?success=' + encodeURIComponent('Password changed'));
    109 });
     85// Wachtwoord-wijzigen is verwijderd: inloggen gaat sinds de Google-OAuth-migratie
     86// volledig via Google, er is geen wachtwoord meer om te wijzigen.
    11087
    11188// ==================== UPLOAD AVATAR ====================
  • src/routes/auth.js

    r89d6536 rc80e78b  
    11import express from 'express';
    22import crypto from 'crypto';
    3 import bcrypt from 'bcryptjs';
    43import { v4 as uuid } from 'uuid';
    54import db from '../config/database.js';
    65import { renderPage } from '../middleware/render.js';
    7 import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js';
    86import { safeNext } from '../middleware/auth.js';
     7import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js';
    98
    109const router = express.Router();
     10const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase();
    1111
    12 // ==================== LOGIN ====================
     12// ==================== LOGIN (alleen Google) ====================
     13// Het oude username/wachtwoord-systeem is verwijderd; inloggen gaat via Google.
    1314router.get('/login', (req, res) => {
    1415  const next = safeNext(req.query.next) || '';
    1516  if (req.session.user) return res.redirect(next || '/');
    1617  renderPage(req, res, 'pages/auth-login', {
    17     pageTitle: 'Login',
     18    pageTitle: 'Inloggen',
    1819    bodyClass: 'on-special',
    19     error: null,
    20     success: req.query.success || null,
    21     username: '',
     20    googleReady: googleConfigured(),
     21    error: req.query.error || null,
    2222    next,
    2323  });
    2424});
    2525
    26 router.post('/login', loginLimiter, (req, res) => {
    27   const { username, password } = req.body;
    28   const next = safeNext(req.body.next) || '';
    29 
    30   if (!username || !password) {
    31     res.status(400);
    32     return renderPage(req, res, 'pages/auth-login', {
    33       pageTitle: 'Login',
    34       bodyClass: 'on-special',
    35       error: 'Username and password required',
    36       username: username || '',
    37       next,
    38     });
     26// Start de Google OAuth-flow.
     27router.get('/google', (req, res) => {
     28  if (!googleConfigured()) {
     29    return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site nog niet geconfigureerd.'));
    3930  }
    40 
    41   const user = db.prepare(
    42     'SELECT * FROM users WHERE username = ? OR email = ?'
    43   ).get(username, username);
    44 
    45   if (!user || !bcrypt.compareSync(password, user.password_hash)) {
    46     res.status(401);
    47     return renderPage(req, res, 'pages/auth-login', {
    48       pageTitle: 'Login',
    49       bodyClass: 'on-special',
    50       error: 'Invalid credentials',
    51       username: username,
    52       next,
    53     });
    54   }
    55 
    56   req.session.user = {
    57     id: user.id,
    58     username: user.username,
    59     email: user.email,
    60     role: user.role,
    61     avatar_url: user.avatar_url,
    62     palette: user.palette,
    63     theme: user.theme,
    64   };
    65 
    66   res.redirect(next || '/');
     31  const next = safeNext(req.query.next) || '';
     32  const state = crypto.randomBytes(16).toString('hex');
     33  req.session.oauthState = state;
     34  req.session.oauthNext = next;
     35  res.redirect(authorizeUrl(state));
    6736});
    6837
    69 // ==================== REGISTER ====================
    70 router.get('/register', (req, res) => {
    71   const next = safeNext(req.query.next) || '';
    72   if (req.session.user) return res.redirect(next || '/');
    73   renderPage(req, res, 'pages/auth-register', {
    74     pageTitle: 'Register',
    75     bodyClass: 'on-special',
    76     error: null,
    77     username: '',
    78     email: '',
    79     next,
    80   });
    81 });
     38// Leid een geldige, unieke username af uit naam/e-mail (schema vereist username).
     39function uniqueUsername(base) {
     40  let u = String(base || 'luisteraar').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 28);
     41  if (u.length < 3) u = 'luisteraar';
     42  let candidate = u, n = 1;
     43  while (db.prepare('SELECT 1 FROM users WHERE username = ?').get(candidate)) {
     44    candidate = (u.slice(0, 26) + n).slice(0, 32);
     45    n++;
     46  }
     47  return candidate;
     48}
    8249
    83 router.post('/register', registerLimiter, (req, res) => {
    84   const { username, email, password } = req.body;
    85   const next = safeNext(req.body.next) || '';
     50// Google callback: wissel code in, haal profiel, vind-of-maak user (op e-mail),
     51// zet de sessie. ADMIN_EMAIL bepaalt wie owner/admin (god) is.
     52router.get('/google/callback', async (req, res) => {
     53  const fail = (msg) => res.redirect('/auth/login?error=' + encodeURIComponent(msg));
     54  try {
     55    const { code, state } = req.query;
     56    if (!code || !state || state !== req.session.oauthState) {
     57      return fail('Login afgebroken of ongeldige sessie.');
     58    }
     59    const next = safeNext(req.session.oauthNext) || '';
     60    delete req.session.oauthState;
     61    delete req.session.oauthNext;
    8662
    87   // Validation
    88   if (!username || !email || !password) {
    89     return renderPage(req, res, 'pages/auth-register', {
    90       pageTitle: 'Register',
    91       bodyClass: 'on-special',
    92       error: 'All fields required',
    93       username: username || '',
    94       email: email || '',
    95       next,
    96     });
     63    const tok = await exchangeCode(String(code));
     64    const info = await fetchUserinfo(tok.access_token);
     65    const email = (info.email || '').trim().toLowerCase();
     66    if (!email || info.email_verified === false) {
     67      return fail('Geen geverifieerd Google-e-mailadres ontvangen.');
     68    }
     69
     70    const isAdmin = !!ADMIN_EMAIL && email === ADMIN_EMAIL;
     71    let user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
     72
     73    if (!user) {
     74      const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
     75      const role = (isAdmin || userCount === 0) ? 'god' : 'member';
     76      const userId = uuid();
     77      const username = uniqueUsername(info.name || email.split('@')[0]);
     78      // password_hash is NOT NULL in het schema; we zetten een onbruikbare
     79      // sentinel (er is geen wachtwoord-login meer).
     80      db.prepare(`
     81        INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub)
     82        VALUES (?, ?, ?, '!google-oauth', ?, ?, 'dark', 'sage', ?)
     83      `).run(userId, username, info.email || email, role, info.picture || null, info.sub || null);
     84
     85      // Eerste/admin-user krijgt een persoonlijke site (zoals de oude flow), maar
     86      // alleen als er nog geen site bestaat.
     87      if (role === 'god' && !db.prepare('SELECT 1 FROM sites LIMIT 1').get()) {
     88        const siteId = uuid();
     89        db.prepare(`
     90          INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language)
     91          VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl')
     92        `).run(siteId, username.toLowerCase(), username + "'s Site", 'Welkom', userId);
     93        db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId);
     94      }
     95      user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
     96    } else {
     97      // Bestaande user (gekoppeld op e-mail): koppel Google-id + avatar als die
     98      // nog ontbreken, en promoveer naar god als dit ADMIN_EMAIL is.
     99      db.prepare(`
     100        UPDATE users
     101        SET google_sub = COALESCE(google_sub, ?),
     102            avatar_url = COALESCE(avatar_url, ?),
     103            updated_at = CURRENT_TIMESTAMP
     104        WHERE id = ?
     105      `).run(info.sub || null, info.picture || null, user.id);
     106      if (isAdmin && user.role !== 'god') {
     107        db.prepare("UPDATE users SET role = 'god' WHERE id = ?").run(user.id);
     108        user.role = 'god';
     109      }
     110    }
     111
     112    req.session.user = {
     113      id: user.id,
     114      username: user.username,
     115      email: user.email,
     116      role: user.role,
     117      avatar_url: user.avatar_url,
     118      palette: user.palette,
     119      theme: user.theme,
     120    };
     121    res.redirect(next || '/');
     122  } catch (e) {
     123    console.error('[auth/google/callback]', e.message);
     124    fail('Google-login mislukt. Probeer opnieuw.');
    97125  }
    98 
    99   if (!/^[a-z0-9_-]{3,32}$/i.test(username)) {
    100     return renderPage(req, res, 'pages/auth-register', {
    101       pageTitle: 'Register',
    102       bodyClass: 'on-special',
    103       error: 'Username: 3-32 characters, letters/numbers/underscore/dash only',
    104       username, email, next,
    105     });
    106   }
    107 
    108   if (password.length < 8) {
    109     return renderPage(req, res, 'pages/auth-register', {
    110       pageTitle: 'Register',
    111       bodyClass: 'on-special',
    112       error: 'Password must be at least 8 characters',
    113       username, email, next,
    114     });
    115   }
    116 
    117   // Check uniqueness
    118   const existing = db.prepare(
    119     'SELECT id FROM users WHERE username = ? OR email = ?'
    120   ).get(username, email);
    121 
    122   if (existing) {
    123     return renderPage(req, res, 'pages/auth-register', {
    124       pageTitle: 'Register',
    125       bodyClass: 'on-special',
    126       error: 'Username or email already taken',
    127       username, email, next,
    128     });
    129   }
    130 
    131   // Create user
    132   const userId = uuid();
    133   const hash = bcrypt.hashSync(password, 10);
    134 
    135   // First user becomes god
    136   const userCount = db.prepare('SELECT COUNT(*) as c FROM users').get().c;
    137   const role = userCount === 0 ? 'god' : 'member';
    138 
    139   db.prepare(`
    140     INSERT INTO users (id, username, email, password_hash, role, theme, palette)
    141     VALUES (?, ?, ?, ?, ?, 'dark', 'sage')
    142   `).run(userId, username, email, hash, role);
    143 
    144   // First user gets a personal site auto-created
    145   if (userCount === 0) {
    146     const siteId = uuid();
    147     const siteSlug = username.toLowerCase();
    148     db.prepare(`
    149       INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language)
    150       VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl')
    151     `).run(siteId, siteSlug, username + "'s Site", 'Welcome to my site', userId);
    152    
    153     db.prepare(`
    154       INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
    155     `).run(siteId, userId);
    156   }
    157 
    158   req.session.user = {
    159     id: userId, username, email, role,
    160     palette: 'sage', theme: 'dark',
    161   };
    162 
    163   res.redirect(next || '/');
    164 });
    165 
    166 // ==================== PASSWORD RESET (request) ====================
    167 router.get('/reset-request', (req, res) => {
    168   if (req.session.user) return res.redirect('/');
    169   renderPage(req, res, 'pages/auth-reset-request', {
    170     pageTitle: 'Reset password',
    171     bodyClass: 'on-special',
    172     error: null,
    173     sent: false,
    174     devResetUrl: null,
    175   });
    176 });
    177 
    178 router.post('/reset-request', registerLimiter, (req, res) => {
    179   const email = (req.body.email || '').trim().toLowerCase();
    180   let devResetUrl = null;
    181 
    182   if (email) {
    183     const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email);
    184     if (user) {
    185       // Generate token (32-byte hex, ~10 min expiry)
    186       const token = crypto.randomBytes(32).toString('hex');
    187       const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min
    188       db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')
    189         .run(token, expires, user.id);
    190 
    191       // No email sender yet — log the reset URL so dev can use it.
    192       const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
    193       const host = req.headers['x-forwarded-host'] || req.get('host');
    194       const url = `${proto}://${host}/auth/reset/${token}`;
    195       console.log(`[password-reset] ${email} -> ${url}`);
    196       if (process.env.NODE_ENV !== 'production') devResetUrl = url;
    197     }
    198   }
    199 
    200   // Anti-enumeration: same response regardless of whether the email exists.
    201   renderPage(req, res, 'pages/auth-reset-request', {
    202     pageTitle: 'Reset password',
    203     bodyClass: 'on-special',
    204     error: null,
    205     sent: true,
    206     devResetUrl,
    207   });
    208 });
    209 
    210 // ==================== PASSWORD RESET (apply) ====================
    211 router.get('/reset/:token', (req, res) => {
    212   const row = db.prepare(`
    213     SELECT id, username FROM users
    214     WHERE reset_token = ? AND reset_token_expires > datetime('now')
    215   `).get(req.params.token);
    216 
    217   if (!row) {
    218     return renderPage(req, res, 'pages/auth-reset', {
    219       pageTitle: 'Reset password',
    220       bodyClass: 'on-special',
    221       error: 'This reset link is invalid or has expired.',
    222       token: null,
    223       username: null,
    224     });
    225   }
    226 
    227   renderPage(req, res, 'pages/auth-reset', {
    228     pageTitle: 'Reset password',
    229     bodyClass: 'on-special',
    230     error: null,
    231     token: req.params.token,
    232     username: row.username,
    233   });
    234 });
    235 
    236 router.post('/reset/:token', (req, res) => {
    237   const { new_password, confirm } = req.body;
    238   const row = db.prepare(`
    239     SELECT id, username FROM users
    240     WHERE reset_token = ? AND reset_token_expires > datetime('now')
    241   `).get(req.params.token);
    242 
    243   const renderError = (msg) => renderPage(req, res, 'pages/auth-reset', {
    244     pageTitle: 'Reset password',
    245     bodyClass: 'on-special',
    246     error: msg,
    247     token: row ? req.params.token : null,
    248     username: row ? row.username : null,
    249   });
    250 
    251   if (!row) return renderError('This reset link is invalid or has expired.');
    252   if (!new_password || new_password.length < 8) {
    253     return renderError('Password must be at least 8 characters');
    254   }
    255   if (new_password !== confirm) {
    256     return renderError('Passwords do not match');
    257   }
    258 
    259   const hash = bcrypt.hashSync(new_password, 10);
    260   db.prepare(`
    261     UPDATE users
    262     SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL,
    263         updated_at = CURRENT_TIMESTAMP
    264     WHERE id = ?
    265   `).run(hash, row.id);
    266 
    267   // Force login (don't auto-log them in — let them prove ownership)
    268   res.redirect('/auth/login?success=' + encodeURIComponent('Password reset — please log in.'));
    269126});
    270127
     
    273130  req.session.destroy(() => res.redirect('/'));
    274131});
    275 
    276132router.post('/logout', (req, res) => {
    277133  req.session.destroy(() => res.redirect('/'));
  • src/views/pages/account.ejs

    r89d6536 rc80e78b  
    55    <div>
    66      <h1>Account</h1>
    7       <p class="ax-tagline">Profiel en wachtwoord.</p>
     7      <p class="ax-tagline">Profiel en avatar.</p>
    88    </div>
    99  </header>
     
    6565  </section>
    6666
    67   <%# ── PASSWORD ──────────────────────────────────────────── %>
     67  <%# ── INLOG ─────────────────────────────────────────────── %>
    6868  <section class="ax-card">
    69     <div class="ax-card-title">Wachtwoord wijzigen</div>
    70     <form action="/account/password" method="post" class="ax-form">
    71       <label class="ax-field">
    72         <span>Huidig wachtwoord</span>
    73         <input type="password" name="current" required autocomplete="current-password">
    74       </label>
    75       <div class="ax-row ax-row-2">
    76         <label class="ax-field">
    77           <span>Nieuw wachtwoord <small>(min 8 tekens)</small></span>
    78           <input type="password" name="new_password" required minlength="8" autocomplete="new-password">
    79         </label>
    80         <label class="ax-field">
    81           <span>Bevestig nieuw wachtwoord</span>
    82           <input type="password" name="confirm" required minlength="8" autocomplete="new-password">
    83         </label>
    84       </div>
    85       <div class="ax-form-actions">
    86         <button type="submit" class="ax-btn ax-btn-primary">🔒 Wachtwoord wijzigen</button>
    87       </div>
    88     </form>
     69    <div class="ax-card-title">Inloggen</div>
     70    <p class="ax-tagline" style="margin:0">Je bent ingelogd via Google (<%= account.email %>). Er is geen apart wachtwoord.</p>
    8971  </section>
    9072
  • src/views/pages/auth-login.ejs

    r89d6536 rc80e78b  
    11<div class="container auth-page">
    2   <h1>Login</h1>
    3   <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
     2  <h1>Inloggen</h1>
    43  <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
    5   <form method="post" action="/auth/login" class="auth-form">
    6     <% if (typeof next !== 'undefined' && next) { %>
    7       <input type="hidden" name="next" value="<%= next %>">
     4
     5  <div class="auth-form auth-google">
     6    <% if (googleReady) { %>
     7      <p class="auth-intro">Log in met je Google-account om mee te praten.</p>
     8      <a class="btn btn-google" href="/auth/google<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">
     9        <svg class="g-icon" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
     10          <path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"/>
     11          <path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
     12          <path fill="#FBBC05" d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33z"/>
     13          <path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
     14        </svg>
     15        <span>Inloggen met Google</span>
     16      </a>
     17    <% } else { %>
     18      <p class="auth-intro">Inloggen via Google is op deze site nog niet ingesteld.</p>
    819    <% } %>
    9     <label>
    10       <span>Username or email</span>
    11       <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username">
    12     </label>
    13     <label>
    14       <span>Password</span>
    15       <input type="password" name="password" required autocomplete="current-password">
    16     </label>
    17     <button type="submit" class="btn btn-primary">Login</button>
    18     <p class="auth-link">No account?
    19       <a href="/auth/register<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">Register</a>
    20     </p>
    21     <p class="auth-link auth-link-muted"><a href="/auth/reset-request">Forgot password?</a></p>
    22   </form>
     20  </div>
    2321</div>
  • src/views/pages/welcome.ejs

    r89d6536 rc80e78b  
    55  <% if (!user) { %>
    66    <div class="welcome-actions">
    7       <a href="/auth/register" class="btn btn-primary">Create your account</a>
    8       <a href="/auth/login" class="btn">Already have one? Login</a>
     7      <a href="/auth/login" class="btn btn-primary">Inloggen met Google</a>
    98    </div>
    10     <p class="welcome-note">The first user becomes <strong>god</strong> and gets a personal site auto-created.</p>
     9    <p class="welcome-note">De eerste gebruiker die inlogt wordt <strong>god</strong> en krijgt automatisch een eigen site.</p>
    1110  <% } else { %>
    1211    <p>Hi <%= user.username %>! No site is configured yet.</p>
  • src/views/partials/shared-styles.ejs

    r89d6536 rc80e78b  
    117117.auth-link { text-align: center; color: var(--ink-muted); font-size: 0.9rem; margin: 1rem 0 0; }
    118118.auth-link a { color: var(--accent); }
     119.auth-google { align-items: stretch; }
     120.auth-intro { text-align: center; color: var(--ink-muted); font-size: 0.95rem; margin: 0 0 1rem; }
     121.btn-google {
     122  justify-content: center; gap: 0.6rem; padding: 0.7rem; font-size: 1rem;
     123  background: #fff; color: #1f1f1f; border: 1px solid #dadce0;
     124}
     125.btn-google:hover { background: #f7f8f8; border-color: #dadce0; }
     126.btn-google .g-icon { flex-shrink: 0; }
    119127
    120128/* HTMX loading indicator */
Note: See TracChangeset for help on using the changeset viewer.