Release v2.20.1: migrate authentication to Better Auth
This commit is contained in:
1 parent
e9a53f577c
commit
fba4f99ff7
92 files changed
+5085
-1943
No files matched your search
@@ -0,0 +1,320 @@
|
||||
const crypto = require('crypto');
|
||||
const { isIP } = require('node:net');
|
||||
const { verifyEncodedPassword } = require('#src/auth');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { formatAccountEmailExpiry, normalizeOtpEmailBody, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
|
||||
|
||||
const LOGIN_RATE_LIMIT_PATHS = ['/sign-in/username', '/sign-in/email', '/two-factor/verify-totp', '/two-factor/verify-backup-code'];
|
||||
|
||||
function getRoleTwoFactorAuthError(currentUser, requestPath) {
|
||||
const actor = currentUser && (currentUser.authenticatedUser || currentUser);
|
||||
if (!actor || !actor.requiresTwoFactor) return null;
|
||||
const path = String(requestPath || '').replace(/\/+$/, '');
|
||||
if (path === '/api/auth/two-factor/disable') {
|
||||
return { code: 'TWO_FACTOR_REQUIRED_BY_ROLE', message: 'Two-factor authentication is required by one of your roles and cannot be disabled.' };
|
||||
}
|
||||
const setupPaths = ['/api/auth/get-session', '/api/auth/sign-out', '/api/auth/verify-password', '/api/auth/two-factor/enable', '/api/auth/two-factor/get-totp-uri', '/api/auth/two-factor/verify-totp', '/api/auth/two-factor/generate-backup-codes'];
|
||||
if (!actor.twoFactorEnabled && !setupPaths.includes(path)) {
|
||||
return { code: 'TWO_FACTOR_ENROLLMENT_REQUIRED', message: 'Set up two-factor authentication in My account to continue.' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createLoginRateLimitResetHook(pool) {
|
||||
return async function (ctx) {
|
||||
const newSession = ctx.context.newSession;
|
||||
if (!LOGIN_RATE_LIMIT_PATHS.includes(ctx.path) || !newSession || !newSession.session) return;
|
||||
const ipAddress = newSession.session.ipAddress || 'no-trusted-ip';
|
||||
await pool.query(
|
||||
'DELETE FROM a_login_attempts WHERE rate_key IN (?, ?, ?, ?)',
|
||||
LOGIN_RATE_LIMIT_PATHS.map(function (path) { return ipAddress + '|' + path; })
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function createPasskeyAuthenticationGuard(pool, APIError) {
|
||||
return async function ({ clientData }) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.two_factor_enabled,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = u.id AND r.require_two_factor = 1
|
||||
) AS role_requires_two_factor
|
||||
FROM a_user_passkeys p
|
||||
JOIN a_users u ON u.id = p.user_id
|
||||
WHERE p.credential_id = ?
|
||||
LIMIT 1`,
|
||||
[String(clientData && clientData.id || '')]
|
||||
);
|
||||
const user = rows && rows[0];
|
||||
if (user && (Number(user.two_factor_enabled) || Number(user.role_requires_two_factor))) {
|
||||
throw new APIError('FORBIDDEN', { message: 'This account requires authenticator verification. Sign in with your username and password.' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePasskeyRpID(baseURL) {
|
||||
const url = String(baseURL || '').trim() ? new URL(String(baseURL).trim()) : null;
|
||||
if (!url) return 'localhost';
|
||||
return url.protocol === 'http:' && isIP(url.hostname) ? 'localhost' : url.hostname;
|
||||
}
|
||||
|
||||
function createAccountLockSessionHook(pool, APIError) {
|
||||
return async function (session) {
|
||||
const [users] = await pool.query('SELECT account_locked FROM a_users WHERE id = ? LIMIT 1', [Number(session.userId)]);
|
||||
if (users && users[0] && users[0].account_locked) {
|
||||
throw new APIError('FORBIDDEN', { message: 'This account is locked. Contact an administrator.' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createPasswordCallbacks(pool, passwordCrypto) {
|
||||
return {
|
||||
hash: function (password) {
|
||||
return passwordCrypto.hashPassword(password);
|
||||
},
|
||||
verify: async function ({ password, hash }) {
|
||||
const storedHash = String(hash || '');
|
||||
if (storedHash.startsWith('pbkdf2-sha256$')) {
|
||||
if (!verifyEncodedPassword(password, storedHash)) return false;
|
||||
const upgradedHash = await passwordCrypto.hashPassword(password);
|
||||
await pool.query(
|
||||
'UPDATE a_accounts SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE provider_id = ? AND password = ?',
|
||||
[upgradedHash, 'credential', storedHash]
|
||||
);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return await passwordCrypto.verifyPassword({ hash: storedHash, password: password });
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function createBetterAuth(pool, options) {
|
||||
const [betterAuthModule, pluginsModule, passkeyModule, nodeModule, apiModule, passwordCryptoModule] = await Promise.all([
|
||||
import('better-auth'),
|
||||
import('better-auth/plugins'),
|
||||
import('@better-auth/passkey'),
|
||||
import('better-auth/node'),
|
||||
import('better-auth/api'),
|
||||
import('better-auth/crypto')
|
||||
]);
|
||||
const secret = String(options && options.secret || '').trim();
|
||||
if (!secret) {
|
||||
throw new Error('AUTH_SECRET is required.');
|
||||
}
|
||||
|
||||
const baseURL = String(options && options.baseURL || '').trim();
|
||||
const authUrl = baseURL ? new URL(baseURL) : null;
|
||||
const additionalTrustedOrigins = String(options && options.trustedOrigins || process.env.AUTH_TRUSTED_ORIGINS || '')
|
||||
.split(',')
|
||||
.map(function (origin) { return origin.trim().replace(/\/$/, ''); })
|
||||
.filter(Boolean);
|
||||
const trustedOrigins = Array.from(new Set([baseURL].concat(additionalTrustedOrigins).filter(Boolean)));
|
||||
const settings = await fetchAppSettings(pool);
|
||||
const sendAccountEmail = options && options.sendAccountEmail;
|
||||
const otpExpiryMinutes = Number(settings['email.otp_expiry_minutes']) || 30;
|
||||
const auth = betterAuthModule.betterAuth({
|
||||
database: pool,
|
||||
secret: secret,
|
||||
appName: 'Pulse Signage',
|
||||
baseURL: baseURL || undefined,
|
||||
trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined,
|
||||
user: {
|
||||
modelName: 'a_users',
|
||||
fields: {
|
||||
email: 'email',
|
||||
emailVerified: 'email_verified',
|
||||
twoFactorEnabled: 'two_factor_enabled',
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'modified_at',
|
||||
}
|
||||
},
|
||||
session: {
|
||||
modelName: 'a_sessions',
|
||||
expiresIn: 14 * 24 * 60 * 60,
|
||||
fields: {
|
||||
expiresAt: 'expires_at',
|
||||
token: 'session_hash',
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'last_used_at',
|
||||
ipAddress: 'ip_address',
|
||||
userAgent: 'user_agent',
|
||||
userId: 'user_id'
|
||||
}
|
||||
},
|
||||
account: {
|
||||
modelName: 'a_accounts',
|
||||
fields: {
|
||||
accountId: 'account_id',
|
||||
providerId: 'provider_id',
|
||||
userId: 'user_id',
|
||||
accessToken: 'access_token',
|
||||
refreshToken: 'refresh_token',
|
||||
idToken: 'id_token',
|
||||
accessTokenExpiresAt: 'access_token_expires_at',
|
||||
refreshTokenExpiresAt: 'refresh_token_expires_at',
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
},
|
||||
verification: {
|
||||
modelName: 'a_verifications',
|
||||
fields: {
|
||||
expiresAt: 'expires_at',
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
password: createPasswordCallbacks(pool, passwordCryptoModule)
|
||||
},
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
storage: 'database',
|
||||
modelName: 'a_login_attempts',
|
||||
fields: {
|
||||
key: 'rate_key',
|
||||
count: 'failed_count',
|
||||
lastRequest: 'last_request'
|
||||
},
|
||||
window: 900,
|
||||
max: 30,
|
||||
customRules: {
|
||||
'/sign-in/username': { window: 900, max: 5 }
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
pluginsModule.username({
|
||||
maxUsernameLength: 255,
|
||||
displayUsername: false,
|
||||
usernameValidator: function (username) {
|
||||
return typeof username === 'string' && username.trim().length > 0 && username.length <= 255;
|
||||
}
|
||||
}),
|
||||
pluginsModule.emailOTP({
|
||||
disableSignUp: true,
|
||||
expiresIn: otpExpiryMinutes * 60,
|
||||
allowedAttempts: 5,
|
||||
storeOTP: 'hashed',
|
||||
changeEmail: { enabled: true, verifyCurrentEmail: false },
|
||||
sendVerificationOTP: async function ({ email, otp, type }) {
|
||||
if (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function') {
|
||||
throw new Error('Email delivery is not configured.');
|
||||
}
|
||||
const reset = type === 'forget-password';
|
||||
const configuredBody = String(settings[reset ? 'email.reset_body' : 'email.verification_body'] || '');
|
||||
const body = normalizeOtpEmailBody(configuredBody);
|
||||
const expiryText = formatAccountEmailExpiry(otpExpiryMinutes, 'minutes');
|
||||
const message = renderAccountEmailTemplate(settings[reset ? 'email.reset_subject' : 'email.verification_subject'], body, {
|
||||
display_name: email,
|
||||
email: email,
|
||||
otp: otp,
|
||||
expiry_time: expiryText
|
||||
});
|
||||
await sendAccountEmail(settings, Object.assign({ to: email }, message));
|
||||
}
|
||||
}),
|
||||
pluginsModule.twoFactor({
|
||||
issuer: 'Pulse Signage',
|
||||
twoFactorTable: 'a_user_two_factors',
|
||||
schema: {
|
||||
user: { fields: { twoFactorEnabled: 'two_factor_enabled' } },
|
||||
twoFactor: {
|
||||
fields: {
|
||||
userId: 'user_id',
|
||||
backupCodes: 'backup_codes',
|
||||
failedVerificationCount: 'failed_verification_count',
|
||||
lockedUntil: 'locked_until'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
passkeyModule.passkey({
|
||||
rpID: resolvePasskeyRpID(baseURL),
|
||||
rpName: 'Pulse Signage',
|
||||
schema: {
|
||||
passkey: {
|
||||
modelName: 'a_user_passkeys',
|
||||
fields: {
|
||||
name: 'name',
|
||||
publicKey: 'public_key',
|
||||
userId: 'user_id',
|
||||
credentialID: 'credential_id',
|
||||
counter: 'counter',
|
||||
deviceType: 'device_type',
|
||||
backedUp: 'backed_up',
|
||||
transports: 'transports',
|
||||
createdAt: 'created_at',
|
||||
aaguid: 'aaguid'
|
||||
}
|
||||
}
|
||||
},
|
||||
authentication: {
|
||||
afterVerification: createPasskeyAuthenticationGuard(pool, apiModule.APIError)
|
||||
}
|
||||
}),
|
||||
{
|
||||
id: 'pulse-login-rate-limit-reset',
|
||||
hooks: {
|
||||
after: [{
|
||||
matcher: function (ctx) { return LOGIN_RATE_LIMIT_PATHS.includes(ctx.path); },
|
||||
handler: apiModule.createAuthMiddleware(createLoginRateLimitResetHook(pool))
|
||||
}]
|
||||
}
|
||||
}
|
||||
],
|
||||
disabledPaths: ['/sign-up/email', '/forget-password', '/reset-password', '/change-password', '/send-verification-email', '/verify-email'],
|
||||
advanced: {
|
||||
ipAddress: {
|
||||
ipAddressHeaders: ['x-forwarded-for']
|
||||
},
|
||||
useSecureCookies: false,
|
||||
defaultCookieAttributes: {
|
||||
secure: baseURL.startsWith('https://')
|
||||
},
|
||||
database: {
|
||||
generateId: function (options) {
|
||||
const model = String(options && options.model || '');
|
||||
if (model === 'user' || model === 'a_users' || model === 'session' || model === 'a_sessions' || model === 'rateLimit' || model === 'a_login_attempts') {
|
||||
return false;
|
||||
}
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
},
|
||||
cookies: {
|
||||
session_token: {
|
||||
name: String(options && options.sessionCookieName || 'digital_signage_session')
|
||||
}
|
||||
}
|
||||
},
|
||||
databaseHooks: {
|
||||
session: {
|
||||
create: {
|
||||
before: createAccountLockSessionHook(pool, apiModule.APIError),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return {
|
||||
auth: auth,
|
||||
toNodeHandler: nodeModule.toNodeHandler,
|
||||
fromNodeHeaders: nodeModule.fromNodeHeaders
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBetterAuth,
|
||||
createPasswordCallbacks,
|
||||
createAccountLockSessionHook,
|
||||
createLoginRateLimitResetHook,
|
||||
createPasskeyAuthenticationGuard,
|
||||
resolvePasskeyRpID,
|
||||
getRoleTwoFactorAuthError
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
|
||||
const { fetchPagedRows } = require('#src/data/utils');
|
||||
const { toVisibleEmail, mapAuthMethod } = require('#src/data/auth-accounts');
|
||||
|
||||
function parseCsvIds(value) {
|
||||
return String(value || '')
|
||||
@@ -57,35 +58,9 @@ async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDir
|
||||
return Object.assign({ roles: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchInvitationsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT i.id, i.email, i.name, i.role_ids_json, i.created_at, i.expires_at, u.username AS created_by_username
|
||||
FROM a_user_invitations i
|
||||
LEFT JOIN a_users u ON u.id = i.created_by
|
||||
WHERE i.used_at IS NULL AND i.expires_at > NOW()
|
||||
ORDER BY i.created_at DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM a_user_invitations WHERE used_at IS NULL AND expires_at > NOW()',
|
||||
searchColumns: ['i.email', 'i.name', 'u.username'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
email: 'i.email',
|
||||
name: 'i.name',
|
||||
created: 'i.created_at',
|
||||
expires: 'i.expires_at',
|
||||
createdBy: 'u.username'
|
||||
},
|
||||
sortKey: sortKey,
|
||||
sortDirection: sortDirection,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ invitations: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchRoleById(pool, roleId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
`SELECT r.id, r.role_key, r.name, r.description, r.require_two_factor, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM a_user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM a_role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM a_roles r
|
||||
@@ -168,7 +143,7 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
|
||||
const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : '';
|
||||
const queryArgs = hasExcludedUserId ? [excludedUserId] : [];
|
||||
const paged = await fetchPagedRows(pool, {
|
||||
selectSql: `SELECT u.id, u.name, u.username, u.email, u.email_verified_at, u.account_locked, u.created_at, u.modified_at,
|
||||
selectSql: `SELECT u.id, u.name, u.username, u.email, u.email_verified, u.account_locked, u.created_at, u.modified_at,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM a_users u
|
||||
@@ -202,6 +177,8 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
|
||||
return {
|
||||
users: (paged.rows || []).map(function (row) {
|
||||
return Object.assign({}, row, {
|
||||
email: toVisibleEmail(row.email),
|
||||
email_verified: Boolean(row.email_verified),
|
||||
roleIds: parseCsvIds(row.role_ids_csv),
|
||||
roleNames: String(row.role_names || '').trim()
|
||||
});
|
||||
@@ -215,10 +192,12 @@ async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey
|
||||
|
||||
async function fetchUserWithRoles(pool, userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.username, u.email, u.email_verified_at, u.account_locked, u.created_at, u.modified_at,
|
||||
`SELECT u.id, u.name, u.username, u.email, u.email_verified, u.account_locked, u.must_change_password, u.two_factor_enabled, u.created_at, u.modified_at,
|
||||
tf.id AS two_factor_id, tf.verified AS two_factor_verified,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM a_users u
|
||||
LEFT JOIN a_user_two_factors tf ON tf.user_id = u.id
|
||||
LEFT JOIN (
|
||||
SELECT ur.user_id,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||
@@ -236,7 +215,22 @@ async function fetchUserWithRoles(pool, userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [accountRows] = await pool.query('SELECT provider_id FROM a_accounts WHERE user_id = ? ORDER BY created_at ASC', [userId]);
|
||||
const authMethods = (accountRows || []).map(function (account) {
|
||||
return mapAuthMethod(account && account.provider_id);
|
||||
}).filter(Boolean);
|
||||
const [passkeyRows] = await pool.query('SELECT id, name, created_at FROM a_user_passkeys WHERE user_id = ? ORDER BY created_at ASC', [userId]);
|
||||
const twoFactorEnabled = Boolean(rows[0].two_factor_enabled);
|
||||
const twoFactorSetupPending = Boolean(rows[0].two_factor_id && !rows[0].two_factor_verified);
|
||||
|
||||
return Object.assign({}, rows[0], {
|
||||
email: toVisibleEmail(rows[0].email),
|
||||
email_verified: Boolean(rows[0].email_verified),
|
||||
twoFactorEnabled: twoFactorEnabled,
|
||||
twoFactorSetupPending: twoFactorSetupPending,
|
||||
canUnlinkTwoFactor: twoFactorEnabled || twoFactorSetupPending,
|
||||
authMethods: authMethods,
|
||||
passkeys: passkeyRows || [],
|
||||
roleIds: parseCsvIds(rows[0].role_ids_csv),
|
||||
roleNames: String(rows[0].role_names || '').trim()
|
||||
});
|
||||
@@ -325,7 +319,6 @@ module.exports = {
|
||||
fetchPermissions,
|
||||
fetchRoles,
|
||||
fetchRolesPage,
|
||||
fetchInvitationsPage,
|
||||
fetchRoleById,
|
||||
fetchRolePermissionKeys,
|
||||
fetchRoleUserIds,
|
||||
|
||||
+32
-74
@@ -1,6 +1,7 @@
|
||||
// Session cookie and current-user helpers for the web auth flow.
|
||||
|
||||
const { hasPermission, normalizePermissionKeys } = require('#src/rbac');
|
||||
const { toVisibleEmail } = require('#src/data/auth-accounts');
|
||||
|
||||
function getRequestOrigin(req) {
|
||||
const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
|
||||
@@ -33,15 +34,10 @@ function normalizeReturnToPath(value, baseUrl) {
|
||||
|
||||
function createSessionService(options) {
|
||||
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
|
||||
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
|
||||
const getConfiguredSessionMaxAgeMs = options && options.getConfiguredSessionMaxAgeMs;
|
||||
const getConfiguredMaxActiveSessions = options && options.getConfiguredMaxActiveSessions;
|
||||
const hashSessionToken = options && options.hashSessionToken;
|
||||
const createSessionToken = options && options.createSessionToken;
|
||||
const authMessageCookieName = 'pulse_auth_message';
|
||||
|
||||
if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') {
|
||||
throw new Error('createSessionService requires the session dependencies.');
|
||||
if (!sessionCookieName) {
|
||||
throw new Error('createSessionService requires a session cookie name.');
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader) {
|
||||
@@ -89,23 +85,6 @@ function createSessionService(options) {
|
||||
appendCookieHeader(res, serializeCookie(sessionCookieName, '', { maxAge: 0 }));
|
||||
}
|
||||
|
||||
function setSessionCookie(res, token) {
|
||||
const hasRequestedMaxAge = arguments.length > 2;
|
||||
const requestedMaxAgeMs = hasRequestedMaxAge ? Number(arguments[2]) : sessionMaxAgeMs;
|
||||
const cookieOptions = hasRequestedMaxAge && arguments[2] === null
|
||||
? {}
|
||||
: { maxAge: Number.isFinite(requestedMaxAgeMs) && requestedMaxAgeMs > 0 ? requestedMaxAgeMs : sessionMaxAgeMs };
|
||||
appendCookieHeader(res, serializeCookie(sessionCookieName, token, cookieOptions));
|
||||
}
|
||||
|
||||
async function getSessionMaxAgeMs() {
|
||||
const configuredValue = typeof getConfiguredSessionMaxAgeMs === 'function'
|
||||
? await getConfiguredSessionMaxAgeMs()
|
||||
: sessionMaxAgeMs;
|
||||
const normalizedValue = Number(configuredValue);
|
||||
return Number.isFinite(normalizedValue) && normalizedValue > 0 ? normalizedValue : sessionMaxAgeMs;
|
||||
}
|
||||
|
||||
function setAuthMessageCookie(res, message) {
|
||||
appendCookieHeader(res, serializeCookie(authMessageCookieName, message, { maxAge: 60 * 1000 }));
|
||||
}
|
||||
@@ -119,27 +98,35 @@ function createSessionService(options) {
|
||||
return message;
|
||||
}
|
||||
|
||||
async function loadCurrentUser(pool, req) {
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
function getSessionToken(req, resolvedSessionToken) {
|
||||
if (resolvedSessionToken) return String(resolvedSessionToken);
|
||||
const betterAuthSessionToken = req && req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token;
|
||||
if (betterAuthSessionToken) return String(betterAuthSessionToken);
|
||||
const cookies = parseCookies(req && req.headers && req.headers.cookie || '');
|
||||
return cookies[sessionCookieName] || '';
|
||||
}
|
||||
|
||||
async function loadCurrentUser(pool, req, resolvedSessionToken) {
|
||||
const token = getSessionToken(req, resolvedSessionToken);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const sessionToken = token;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT s.user_id, s.view_as_user_id, s.view_as_expires_at,
|
||||
u.id, u.name, u.username, u.email, u.email_verified_at, u.pending_email, u.must_change_password,
|
||||
u.id, u.name, u.username, u.email, u.email_verified, u.pending_email, u.must_change_password, u.two_factor_enabled,
|
||||
preview.id AS preview_id, preview.name AS preview_name, preview.username AS preview_username,
|
||||
preview.email AS preview_email, preview.email_verified_at AS preview_email_verified_at,
|
||||
preview.pending_email AS preview_pending_email, preview.must_change_password AS preview_must_change_password
|
||||
preview.email AS preview_email, preview.email_verified AS preview_email_verified,
|
||||
preview.pending_email AS preview_pending_email, preview.must_change_password AS preview_must_change_password,
|
||||
preview.two_factor_enabled AS preview_two_factor_enabled
|
||||
FROM a_sessions s
|
||||
JOIN a_users u ON u.id = s.user_id
|
||||
LEFT JOIN a_users preview ON preview.id = s.view_as_user_id AND s.view_as_expires_at > NOW()
|
||||
WHERE s.session_hash = ?
|
||||
AND s.expires_at > NOW()
|
||||
LIMIT 1`,
|
||||
[tokenHash]
|
||||
[sessionToken]
|
||||
);
|
||||
if (!rows.length) {
|
||||
return null;
|
||||
@@ -148,7 +135,7 @@ function createSessionService(options) {
|
||||
const userId = Number(rows[0].id);
|
||||
async function loadAccess(userId) {
|
||||
const [roleRows] = await pool.query(
|
||||
`SELECT r.role_key
|
||||
`SELECT r.role_key, r.require_two_factor
|
||||
FROM a_user_roles ur
|
||||
JOIN a_roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
@@ -165,6 +152,7 @@ function createSessionService(options) {
|
||||
[userId]
|
||||
);
|
||||
return {
|
||||
requiresTwoFactor: roleRows.some(function (row) { return Boolean(row.require_two_factor); }),
|
||||
roleKeys: roleRows.map(function (row) {
|
||||
return String(row.role_key || '').trim();
|
||||
}).filter(Boolean),
|
||||
@@ -176,7 +164,11 @@ function createSessionService(options) {
|
||||
|
||||
const actorAccess = await loadAccess(userId);
|
||||
const authenticatedUser = Object.assign({}, rows[0], {
|
||||
email: toVisibleEmail(rows[0].email),
|
||||
email_verified: Boolean(rows[0].email_verified),
|
||||
mustChangePassword: Boolean(rows[0].must_change_password),
|
||||
twoFactorEnabled: Boolean(rows[0].two_factor_enabled),
|
||||
requiresTwoFactor: actorAccess.requiresTwoFactor,
|
||||
roleKeys: actorAccess.roleKeys,
|
||||
permissionKeys: actorAccess.permissionKeys
|
||||
});
|
||||
@@ -188,11 +180,11 @@ function createSessionService(options) {
|
||||
if (Number(rows[0].view_as_user_id) > 0 && !canContinuePreview) {
|
||||
await pool.query(
|
||||
'UPDATE a_sessions SET view_as_user_id = NULL, view_as_expires_at = NULL WHERE session_hash = ? AND user_id = ?',
|
||||
[tokenHash, userId]
|
||||
[sessionToken, userId]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
|
||||
await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, sessionToken]);
|
||||
|
||||
if (!canContinuePreview) {
|
||||
return authenticatedUser;
|
||||
@@ -203,10 +195,12 @@ function createSessionService(options) {
|
||||
id: previewUserId,
|
||||
name: rows[0].preview_name,
|
||||
username: rows[0].preview_username,
|
||||
email: rows[0].preview_email,
|
||||
email_verified_at: rows[0].preview_email_verified_at,
|
||||
email: toVisibleEmail(rows[0].preview_email),
|
||||
email_verified: Boolean(rows[0].preview_email_verified),
|
||||
pending_email: rows[0].preview_pending_email,
|
||||
mustChangePassword: Boolean(rows[0].preview_must_change_password),
|
||||
twoFactorEnabled: Boolean(rows[0].preview_two_factor_enabled),
|
||||
requiresTwoFactor: previewAccess.requiresTwoFactor,
|
||||
roleKeys: previewAccess.roleKeys,
|
||||
permissionKeys: previewAccess.permissionKeys,
|
||||
authenticatedUser: authenticatedUser,
|
||||
@@ -216,9 +210,7 @@ function createSessionService(options) {
|
||||
}
|
||||
|
||||
function getSessionHash(req) {
|
||||
const cookies = parseCookies(req && req.headers && req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
return token ? hashSessionToken(token) : '';
|
||||
return getSessionToken(req);
|
||||
}
|
||||
|
||||
async function startViewAsUser(pool, req, actorUserId, targetUserId) {
|
||||
@@ -249,37 +241,6 @@ function createSessionService(options) {
|
||||
return Number(result && result.affectedRows) === 1;
|
||||
}
|
||||
|
||||
async function createUserSession(pool, userId, configuredMaxAgeMs, metadata) {
|
||||
const token = createSessionToken();
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const maxAgeMs = Number.isFinite(Number(configuredMaxAgeMs)) && Number(configuredMaxAgeMs) > 0
|
||||
? Number(configuredMaxAgeMs)
|
||||
: await getSessionMaxAgeMs();
|
||||
const expiresAt = new Date(Date.now() + maxAgeMs);
|
||||
const sessionMetadata = metadata && typeof metadata === 'object' ? metadata : {};
|
||||
await pool.query(
|
||||
'INSERT INTO a_sessions (session_hash, user_id, ip_address, user_agent, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[tokenHash, userId, String(sessionMetadata.ipAddress || '').slice(0, 255) || null, String(sessionMetadata.userAgent || '').slice(0, 512) || null, expiresAt, userId, userId]
|
||||
);
|
||||
|
||||
if (typeof getConfiguredMaxActiveSessions === 'function') {
|
||||
const maxActiveSessions = Number(await getConfiguredMaxActiveSessions());
|
||||
if (Number.isInteger(maxActiveSessions) && maxActiveSessions > 0) {
|
||||
const [sessionRows] = await pool.query(
|
||||
'SELECT id, session_hash FROM a_sessions WHERE user_id = ? AND expires_at > NOW() ORDER BY last_used_at ASC, created_at ASC, id ASC',
|
||||
[userId]
|
||||
);
|
||||
const sessionsToRemove = (sessionRows || []).filter(function (session) {
|
||||
return session.session_hash !== tokenHash;
|
||||
}).slice(0, Math.max(0, sessionRows.length - maxActiveSessions));
|
||||
for (const session of sessionsToRemove) {
|
||||
await pool.query('DELETE FROM a_sessions WHERE id = ? AND user_id = ?', [session.id, userId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.currentUser) {
|
||||
return next();
|
||||
@@ -293,14 +254,11 @@ function createSessionService(options) {
|
||||
parseCookies: parseCookies,
|
||||
serializeCookie: serializeCookie,
|
||||
clearSessionCookie: clearSessionCookie,
|
||||
setSessionCookie: setSessionCookie,
|
||||
setAuthMessageCookie: setAuthMessageCookie,
|
||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
startViewAsUser: startViewAsUser,
|
||||
stopViewAsUser: stopViewAsUser,
|
||||
createUserSession: createUserSession,
|
||||
getSessionMaxAgeMs: getSessionMaxAgeMs,
|
||||
requireAuth: requireAuth,
|
||||
getRequestOrigin: getRequestOrigin
|
||||
};
|
||||
|
||||
@@ -2,24 +2,27 @@ const crypto = require('crypto');
|
||||
|
||||
async function collectLocalControlUsers(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DISTINCT u.username, u.password_hash, u.password_salt
|
||||
`SELECT DISTINCT u.username, a.password AS password_hash
|
||||
FROM a_users u
|
||||
JOIN a_accounts a ON a.user_id = u.id AND a.provider_id = 'credential'
|
||||
JOIN a_user_roles ur ON ur.user_id = u.id
|
||||
JOIN a_role_permissions rp ON rp.role_id = ur.role_id
|
||||
JOIN a_permissions p ON p.id = rp.permission_id
|
||||
WHERE u.account_locked = 0
|
||||
AND u.must_change_password = 0
|
||||
AND a.password IS NOT NULL
|
||||
AND a.password <> ''
|
||||
AND a.password NOT LIKE 'app-user:%'
|
||||
AND p.permission_key = 'clients.allow'
|
||||
ORDER BY u.username ASC`
|
||||
);
|
||||
return (rows || []).map(function (user) {
|
||||
return {
|
||||
username_hash: crypto.createHash('sha256').update(String(user && user.username || '').trim()).digest('hex'),
|
||||
password_hash: String(user && user.password_hash || '').trim(),
|
||||
password_salt: String(user && user.password_salt || '').trim()
|
||||
password_hash: String(user && user.password_hash || '').trim()
|
||||
};
|
||||
}).filter(function (user) {
|
||||
return Boolean(user.username_hash && user.password_hash && user.password_salt);
|
||||
return Boolean(user.username_hash && user.password_hash);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ async function initializeWebServer(options) {
|
||||
throw new Error('initializeWebServer requires the web startup dependencies.');
|
||||
}
|
||||
|
||||
await common.ensureSchema(pool, { mediaDir: mediaDir });
|
||||
await common.bootstrapDatabase(pool);
|
||||
if (!(options && options.databaseInitialized)) {
|
||||
await common.ensureSchema(pool, { mediaDir: mediaDir });
|
||||
await common.bootstrapDatabase(pool);
|
||||
}
|
||||
await backgroundTaskQueue.initialize();
|
||||
|
||||
await initializeBackgroundTasks({
|
||||
|
||||
@@ -43,7 +43,7 @@ module.exports = function registerMiddleware(app, deps) {
|
||||
return next(error);
|
||||
}
|
||||
|
||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/forgot-password' || req.path === '/reset-password' || req.path === '/verify-email' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font' || req.path === '/api/internal/sync/player-control') {
|
||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path === '/forgot-password' || req.path === '/reset-password' || req.path === '/slides/popup-preview' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0 || req.path === '/api/internal/sync/player-media' || req.path === '/api/internal/sync/player-font' || req.path === '/api/internal/sync/player-control') {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -55,6 +55,14 @@ module.exports = function registerMiddleware(app, deps) {
|
||||
return res.redirect('/account/password');
|
||||
}
|
||||
|
||||
const actor = req.authenticatedUser || req.currentUser;
|
||||
if (actor && actor.requiresTwoFactor && !actor.twoFactorEnabled && req.path !== '/account' && req.path !== '/account/password' && req.path !== '/settings/view-as/stop') {
|
||||
if (req.path.indexOf('/api/') === 0) {
|
||||
return res.status(403).json({ code: 'TWO_FACTOR_ENROLLMENT_REQUIRED', message: 'Set up two-factor authentication in My account to continue.' });
|
||||
}
|
||||
return res.redirect('/account');
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,19 +10,14 @@ module.exports = {
|
||||
renderLoginPage: require(routePath('auth', 'login')),
|
||||
renderForgotPasswordPage: require(routePath('auth', 'forgot-password')),
|
||||
renderResetPasswordPage: require(routePath('auth', 'reset-password')),
|
||||
renderAcceptInvitePage: require(routePath('auth', 'accept-invite')),
|
||||
renderNoAccessPage: require(routePath('auth', 'no-access')),
|
||||
renderEmailVerifiedPage: require(routePath('auth', 'email-verified')),
|
||||
renderEmailVerificationErrorPage: require(routePath('auth', 'email-verification-error')),
|
||||
renderAccountPage: require(routePath('account', 'password')),
|
||||
renderRequiredPasswordPage: require(routePath('account', 'password-required')),
|
||||
renderSettingsPage: require(routePath('settings', 'index')),
|
||||
renderAboutPage: require(routePath('settings', 'about', 'index')),
|
||||
renderChangelogPage: require(routePath('settings', 'changelog', 'index')),
|
||||
renderUsersPage: require(routePath('settings', 'users', 'list')),
|
||||
renderInvitationsPage: require(routePath('settings', 'invitations', 'list')),
|
||||
renderUsersAddPage: require(routePath('settings', 'users', 'add')),
|
||||
renderUsersInvitePage: require(routePath('settings', 'users', 'invite')),
|
||||
renderUsersEditPage: require(routePath('settings', 'users', 'edit')),
|
||||
renderDashboardPage: require(routePath('signage', 'dashboard', 'index')),
|
||||
renderConnectedClientsPage: require(routePath('signage', 'clients', 'list')),
|
||||
|
||||
@@ -447,6 +447,49 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-card .login-box-msg {
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-card .form-label {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.login-card .input-group-text {
|
||||
min-width: 2.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-or-divider {
|
||||
align-items: center;
|
||||
color: var(--bs-secondary-color);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.login-or-divider::before,
|
||||
.login-or-divider::after {
|
||||
background: var(--bs-border-color);
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 359.98px) {
|
||||
.login-card [data-login-actions] {
|
||||
align-items: flex-start !important;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.login-card [data-login-submit] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.email-verified-card {
|
||||
max-width: 26rem;
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
(function () {
|
||||
var currentPasswordInput = document.querySelector('[data-account-current-password]');
|
||||
var profileFormInput = document.querySelector('[data-account-profile-current-password]');
|
||||
var passwordFormInput = document.querySelector('[data-account-password-current-password]');
|
||||
var profileForm = profileFormInput && profileFormInput.form;
|
||||
var passwordForm = passwordFormInput && passwordFormInput.form;
|
||||
|
||||
if (!currentPasswordInput || !profileFormInput || !passwordFormInput || !profileForm || !passwordForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
function syncCurrentPassword() {
|
||||
profileFormInput.value = currentPasswordInput.value;
|
||||
passwordFormInput.value = currentPasswordInput.value;
|
||||
}
|
||||
|
||||
function clearCurrentPassword() {
|
||||
currentPasswordInput.value = '';
|
||||
currentPasswordInput.setCustomValidity('');
|
||||
currentPasswordInput.removeAttribute('required');
|
||||
profileFormInput.value = '';
|
||||
passwordFormInput.value = '';
|
||||
passwordForm.querySelectorAll('input[type="password"]').forEach(function (input) {
|
||||
input.value = '';
|
||||
input.setCustomValidity('');
|
||||
});
|
||||
profileForm.classList.remove('was-validated');
|
||||
passwordForm.classList.remove('was-validated');
|
||||
}
|
||||
|
||||
currentPasswordInput.addEventListener('input', syncCurrentPassword);
|
||||
currentPasswordInput.addEventListener('input', function () {
|
||||
currentPasswordInput.setCustomValidity('');
|
||||
});
|
||||
document.addEventListener('submit', function (event) {
|
||||
if (event.target !== profileForm && event.target !== passwordForm) {
|
||||
return;
|
||||
}
|
||||
if (!currentPasswordInput.value) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
currentPasswordInput.setCustomValidity('Current password is required.');
|
||||
currentPasswordInput.reportValidity();
|
||||
return;
|
||||
}
|
||||
currentPasswordInput.setCustomValidity('');
|
||||
syncCurrentPassword();
|
||||
}, true);
|
||||
passwordForm.addEventListener('submit', syncCurrentPassword);
|
||||
document.addEventListener('web-async-save:success', function (event) {
|
||||
if (event.detail && event.detail.form !== profileForm && event.detail.form !== passwordForm) {
|
||||
return;
|
||||
}
|
||||
clearCurrentPassword();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,70 @@
|
||||
(function () {
|
||||
var client = window.pulsePasskeyAuth;
|
||||
var addForm = document.querySelector('[data-passkey-add]');
|
||||
var addTrigger = document.querySelector('[data-passkey-add-trigger]');
|
||||
var feedback = document.querySelector('[data-passkey-feedback]');
|
||||
var progress = document.querySelector('[data-passkey-progress]');
|
||||
var unavailable = document.querySelector('[data-passkey-unavailable]');
|
||||
var passkeyConfig = document.querySelector('[data-passkey-rp-id]');
|
||||
var passkeyRpID = passkeyConfig && passkeyConfig.dataset && String(passkeyConfig.dataset.passkeyRpId || '').toLowerCase();
|
||||
var currentHostname = String(window.location.hostname || '').toLowerCase();
|
||||
var passkeyHostnameMatches = passkeyRpID && (currentHostname === passkeyRpID || currentHostname.endsWith('.' + passkeyRpID));
|
||||
var deleteButtons = document.querySelectorAll('[data-passkey-delete]');
|
||||
|
||||
if (!window.isSecureContext || !navigator.credentials || !client || !passkeyHostnameMatches) {
|
||||
if (addForm) addForm.hidden = true;
|
||||
if (addTrigger) addTrigger.disabled = true;
|
||||
if (unavailable) unavailable.hidden = false;
|
||||
deleteButtons.forEach(function (button) { button.disabled = true; });
|
||||
return;
|
||||
}
|
||||
|
||||
function showError(error) {
|
||||
if (!feedback) return;
|
||||
feedback.textContent = error && error.message || 'The passkey request could not be completed.';
|
||||
feedback.hidden = false;
|
||||
}
|
||||
|
||||
if (addForm) {
|
||||
var setupModal = document.querySelector('#passkey-setup-modal');
|
||||
if (setupModal) {
|
||||
setupModal.addEventListener('shown.bs.modal', function () {
|
||||
addForm.elements.name.focus();
|
||||
});
|
||||
}
|
||||
|
||||
addForm.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
var button = addForm.querySelector('button[type="submit"]');
|
||||
button.disabled = true;
|
||||
if (feedback) feedback.hidden = true;
|
||||
if (progress) progress.hidden = false;
|
||||
try {
|
||||
var name = addForm.elements.name.value.trim();
|
||||
var result = await client.passkey.addPasskey(name ? { name: name } : {});
|
||||
if (result.error) throw new Error(result.error.message || 'Unable to add this passkey.');
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
if (progress) progress.hidden = true;
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteButtons.forEach(function (button) {
|
||||
button.addEventListener('click', async function () {
|
||||
if (!window.confirm('Remove this passkey?')) return;
|
||||
button.disabled = true;
|
||||
if (feedback) feedback.hidden = true;
|
||||
try {
|
||||
var result = await client.passkey.deletePasskey({ id: button.dataset.passkeyDelete });
|
||||
if (result.error) throw new Error(result.error.message || 'Unable to remove this passkey.');
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,146 @@
|
||||
(function () {
|
||||
function getFeedback(form, selector) {
|
||||
return form.querySelector(selector);
|
||||
}
|
||||
|
||||
function showError(target, message) {
|
||||
if (!target) return;
|
||||
target.textContent = message;
|
||||
target.hidden = false;
|
||||
}
|
||||
|
||||
async function request(endpoint, body) {
|
||||
const response = await fetch('/api/auth/two-factor/' + endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {})
|
||||
});
|
||||
const data = await response.json().catch(function () { return {}; });
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.code || 'Two-factor request failed.');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderBackupCodes(container, codes) {
|
||||
const list = container && container.querySelector('[data-two-factor-codes-list]');
|
||||
if (!container || !list) return;
|
||||
list.replaceChildren();
|
||||
(Array.isArray(codes) ? codes : []).forEach(function (code) {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = code;
|
||||
list.appendChild(item);
|
||||
});
|
||||
container.hidden = false;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-two-factor-download]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
const container = button.closest('[data-two-factor-codes]');
|
||||
const list = container && container.querySelector('[data-two-factor-codes-list]');
|
||||
const codes = list ? Array.from(list.querySelectorAll('li')).map(function (item) {
|
||||
return item.textContent.trim();
|
||||
}).filter(Boolean) : [];
|
||||
if (!codes.length) return;
|
||||
const file = new Blob(['Pulse Signage recovery codes\n\n' + codes.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(file);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'pulse-signage-recovery-codes.txt';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
});
|
||||
|
||||
const enableForm = document.querySelector('[data-two-factor-enable]');
|
||||
if (enableForm) {
|
||||
const setupPanel = document.querySelector('[data-two-factor-setup]');
|
||||
const feedback = getFeedback(enableForm, '[data-two-factor-enable-feedback]');
|
||||
enableForm.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
const button = enableForm.querySelector('button[type="submit"]');
|
||||
const password = enableForm.elements.password.value;
|
||||
feedback.hidden = true;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await request('enable', { password: password, method: 'totp', issuer: 'Pulse Signage' });
|
||||
if (result.method !== 'totp' || !result.totpURI) throw new Error('Authenticator setup could not be started.');
|
||||
const qrContainer = document.querySelector('[data-two-factor-qr]');
|
||||
qrContainer.replaceChildren();
|
||||
const qrCode = new window.QRCodeStyling({
|
||||
width: 256,
|
||||
height: 256,
|
||||
type: 'svg',
|
||||
data: result.totpURI,
|
||||
dotsOptions: { color: '#17212b', type: 'rounded' },
|
||||
backgroundOptions: { color: '#ffffff' },
|
||||
cornersSquareOptions: { color: '#17212b', type: 'extra-rounded' }
|
||||
});
|
||||
qrCode.append(qrContainer);
|
||||
const secret = new URL(result.totpURI).searchParams.get('secret');
|
||||
document.querySelector('[data-two-factor-secret]').textContent = secret || '';
|
||||
renderBackupCodes(document.querySelector('[data-two-factor-codes]'), result.backupCodes);
|
||||
setupPanel.hidden = false;
|
||||
enableForm.hidden = true;
|
||||
} catch (error) {
|
||||
showError(feedback, error.message);
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const verifyForm = document.querySelector('[data-two-factor-verify]');
|
||||
const verifyFeedback = getFeedback(verifyForm, '[data-two-factor-verify-feedback]');
|
||||
verifyForm.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
const button = verifyForm.querySelector('button[type="submit"]');
|
||||
verifyFeedback.hidden = true;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await request('verify-totp', { code: verifyForm.elements.code.value.trim() });
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
showError(verifyFeedback, error.message);
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const disableForm = document.querySelector('[data-two-factor-disable]');
|
||||
if (disableForm) {
|
||||
const feedback = getFeedback(disableForm, '[data-two-factor-disable-feedback]');
|
||||
disableForm.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
const button = disableForm.querySelector('button[type="submit"]');
|
||||
feedback.hidden = true;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await request('disable', { password: disableForm.elements.password.value });
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
showError(feedback, error.message);
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const recoveryForm = document.querySelector('[data-two-factor-regenerate-codes]');
|
||||
if (recoveryForm) {
|
||||
const feedback = document.querySelector('[data-two-factor-codes-feedback]');
|
||||
recoveryForm.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
const button = recoveryForm.querySelector('button[type="submit"]');
|
||||
feedback.hidden = true;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await request('generate-backup-codes', { password: recoveryForm.elements.password.value });
|
||||
renderBackupCodes(document.querySelector('[data-two-factor-codes]'), result.backupCodes);
|
||||
recoveryForm.elements.password.value = '';
|
||||
} catch (error) {
|
||||
showError(feedback, error.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,137 @@
|
||||
(function () {
|
||||
var form = document.querySelector('[data-better-auth-login]');
|
||||
if (!form) return;
|
||||
|
||||
var feedback = form.querySelector('[data-login-feedback]');
|
||||
var submitButton = form.querySelector('[data-login-submit]');
|
||||
var credentialsPanel = form.querySelector('[data-login-credentials]');
|
||||
var rememberPanel = form.querySelector('[data-login-remember]');
|
||||
var twoFactorPanel = form.querySelector('[data-login-two-factor]');
|
||||
var codeInput = form.elements.two_factor_code;
|
||||
var codeLabel = form.querySelector('[data-two-factor-code-label]');
|
||||
var toggleMethodButton = form.querySelector('[data-two-factor-toggle-method]');
|
||||
var backButton = form.querySelector('[data-two-factor-back]');
|
||||
var passkeyPanel = form.querySelector('[data-passkey-login-panel]');
|
||||
var passkeyButton = form.querySelector('[data-passkey-login]');
|
||||
var passkeyUnavailable = form.querySelector('[data-passkey-unavailable]');
|
||||
var passkeyRpID = passkeyPanel && passkeyPanel.dataset && String(passkeyPanel.dataset.passkeyRpId || '').toLowerCase();
|
||||
var currentHostname = String(window.location.hostname || '').toLowerCase();
|
||||
var passkeyHostnameMatches = passkeyRpID && (currentHostname === passkeyRpID || currentHostname.endsWith('.' + passkeyRpID));
|
||||
var challengeMethods = [];
|
||||
var challengeMode = 'totp';
|
||||
|
||||
function showChallenge(methods) {
|
||||
challengeMethods = Array.isArray(methods) ? methods : [];
|
||||
challengeMode = 'totp';
|
||||
credentialsPanel.hidden = true;
|
||||
rememberPanel.hidden = true;
|
||||
twoFactorPanel.hidden = false;
|
||||
if (passkeyPanel) passkeyPanel.hidden = true;
|
||||
codeInput.disabled = false;
|
||||
form.elements.trust_device.disabled = false;
|
||||
codeInput.value = '';
|
||||
codeLabel.textContent = 'Authenticator code';
|
||||
codeInput.inputMode = 'numeric';
|
||||
toggleMethodButton.hidden = false;
|
||||
toggleMethodButton.textContent = 'Use recovery code';
|
||||
submitButton.textContent = 'Verify code';
|
||||
submitButton.disabled = false;
|
||||
codeInput.focus();
|
||||
}
|
||||
|
||||
function resetChallenge() {
|
||||
challengeMethods = [];
|
||||
challengeMode = 'totp';
|
||||
credentialsPanel.hidden = false;
|
||||
rememberPanel.hidden = false;
|
||||
twoFactorPanel.hidden = true;
|
||||
if (passkeyPanel) passkeyPanel.hidden = false;
|
||||
codeInput.disabled = true;
|
||||
form.elements.trust_device.disabled = true;
|
||||
form.elements.trust_device.checked = false;
|
||||
codeInput.value = '';
|
||||
toggleMethodButton.textContent = 'Use recovery code';
|
||||
submitButton.textContent = 'Sign in';
|
||||
feedback.hidden = true;
|
||||
}
|
||||
|
||||
toggleMethodButton.addEventListener('click', function () {
|
||||
challengeMode = challengeMode === 'totp' ? 'backup' : 'totp';
|
||||
codeLabel.textContent = challengeMode === 'backup' ? 'Recovery code' : 'Authenticator code';
|
||||
codeInput.inputMode = challengeMode === 'backup' ? 'text' : 'numeric';
|
||||
codeInput.autocomplete = challengeMode === 'backup' ? 'off' : 'one-time-code';
|
||||
codeInput.value = '';
|
||||
toggleMethodButton.textContent = challengeMode === 'backup' ? 'Use authenticator code' : 'Use recovery code';
|
||||
codeInput.focus();
|
||||
});
|
||||
|
||||
backButton.addEventListener('click', resetChallenge);
|
||||
|
||||
if (passkeyButton) {
|
||||
if (!window.isSecureContext || !navigator.credentials || !window.pulsePasskeyAuth || !passkeyHostnameMatches) {
|
||||
passkeyButton.disabled = true;
|
||||
if (passkeyUnavailable) passkeyUnavailable.hidden = false;
|
||||
} else {
|
||||
passkeyButton.addEventListener('click', async function () {
|
||||
feedback.hidden = true;
|
||||
passkeyButton.disabled = true;
|
||||
try {
|
||||
var result = await window.pulsePasskeyAuth.signIn.passkey({ autoFill: false });
|
||||
if (result.error) throw new Error(result.error.message || 'Unable to sign in with a passkey.');
|
||||
var returnTo = new URLSearchParams(window.location.search).get('returnTo') || '/';
|
||||
window.location.assign(returnTo.charAt(0) === '/' && returnTo.charAt(1) !== '/' ? returnTo : '/');
|
||||
} catch (error) {
|
||||
feedback.textContent = error.message || 'Unable to sign in with a passkey.';
|
||||
feedback.hidden = false;
|
||||
passkeyButton.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
var initialMessage = document.querySelector('[data-login-initial-message]');
|
||||
if (initialMessage) initialMessage.remove();
|
||||
feedback.hidden = true;
|
||||
submitButton.disabled = true;
|
||||
|
||||
try {
|
||||
var inChallenge = challengeMethods.length > 0;
|
||||
var identity = inChallenge ? '' : form.elements.username.value.trim();
|
||||
var useEmail = identity.indexOf('@') !== -1;
|
||||
var endpoint = inChallenge
|
||||
? '/api/auth/two-factor/' + (challengeMode === 'backup' ? 'verify-backup-code' : 'verify-totp')
|
||||
: form.action;
|
||||
var payload = inChallenge
|
||||
? { code: codeInput.value.trim(), trustDevice: form.elements.trust_device.checked }
|
||||
: {
|
||||
username: useEmail ? undefined : identity,
|
||||
email: useEmail ? identity : undefined,
|
||||
password: form.elements.password.value,
|
||||
rememberMe: form.elements.remember_me.checked
|
||||
};
|
||||
var response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
var result = await response.json().catch(function () { return {}; });
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || (inChallenge ? 'Unable to verify the two-factor code.' : 'Unable to sign in. Check your username and password.'));
|
||||
}
|
||||
if (!inChallenge && result.twoFactorRedirect) {
|
||||
showChallenge(result.twoFactorMethods);
|
||||
return;
|
||||
}
|
||||
|
||||
var returnTo = new URLSearchParams(window.location.search).get('returnTo') || '/';
|
||||
window.location.assign(returnTo.charAt(0) === '/' && returnTo.charAt(1) !== '/' ? returnTo : '/');
|
||||
} catch (error) {
|
||||
feedback.textContent = error.message || 'Unable to sign in. Check your username and password.';
|
||||
feedback.hidden = false;
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createAuthClient } from 'better-auth/client';
|
||||
import { passkeyClient } from '@better-auth/passkey/client';
|
||||
|
||||
window.pulsePasskeyAuth = createAuthClient({
|
||||
plugins: [passkeyClient()]
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
(()=>{var le=Symbol.for("better-auth:broadcast-channel"),ft=()=>Math.floor(Date.now()/1e3),dt=class{listeners=new Set;name;constructor(e="better-auth.message"){this.name=e}subscribe(e){return this.listeners.add(e),()=>{this.listeners.delete(e)}}post(e){if(!(typeof window>"u"))try{localStorage.setItem(this.name,JSON.stringify({...e,timestamp:ft()}))}catch{}}setup(){if(typeof window>"u"||typeof window.addEventListener>"u")return()=>{};let e=t=>{if(t.key!==this.name)return;let r=JSON.parse(t.newValue??"{}");r?.event!=="session"||!r?.data||this.listeners.forEach(n=>n(r))};return window.addEventListener("storage",e),()=>{window.removeEventListener("storage",e)}}};function J(e="better-auth.message"){return globalThis[le]||(globalThis[le]=new dt(e)),globalThis[le]}var O=[],H=0,C=null,ce=4,ht=globalThis.nanostoresGlobal||={epoch:0},pt=()=>{let e,t;for(;H<O.length;){t=H,H+=ce;let r=C?.get(O[t]);if(r){if(!r.has(O[t+1]))continue;r.clear()}try{O[t](O[t+1].value,O[t+2],O[t+3])}catch(n){e=n}}if(O.length=H=0,e)throw e};var D=e=>{let t=[],r={eq:Object.is,get(){return r.lc||r.listen(()=>{})(),r.value},init:e,lc:0,listen(n){return r.lc=t.push(n),()=>{C?.get(n)?.delete(r);for(let s=H;s<O.length;)O[s]===n&&O[s+1]===r?O.splice(s,ce):s+=ce;let o=t.indexOf(n);~o&&(t.splice(o,1),--r.lc||r.off())}},notify(n,o){ht.epoch++;let s=!O.length&&!C;for(let l of t){if(C){let a=C.get(l);if(a||C.set(l,a=new Set),a.has(r))continue;a.add(r)}O.push(l,r,n,C?void 0:o)}s&&pt()},off(){},set(n){let o=r.value;r.eq(o,n)||(r.value=n,r.notify(o))},subscribe(n){let o=r.listen(n);return n(r.value),o},value:e};return r};var mt=2;var Et=5,$=6,z=10,_e=(e,t,r,n)=>(e.events=e.events||{},e.events[r+z]||(e.events[r+z]=n(o=>{e.events[r].reduceRight((s,l)=>(l(s),s),{shared:{},...o})})),e.events[r]=e.events[r]||[],e.events[r].push(t),()=>{let o=e.events[r],s=o.indexOf(t);~s&&(o.splice(s,1),o.length||(e.events[r+z](),delete e.events[r+z]))});var fe=(e,t)=>_e(e,t,mt,r=>{let n=e.set,o=e.setKey;return e.setKey&&(e.setKey=(s,l)=>{let a;if(r({abort:()=>{a=!0},changed:s,newValue:{...e.value,[s]:l}}),!a)return o(s,l)}),e.set=s=>{let l;if(r({abort:()=>{l=!0},newValue:s}),!l)return n(s)},()=>{e.set=n,e.setKey=o}});var Q=1e3,j=(e,t)=>_e(e,n=>{let o=t(n);o&&e.events[$].push(o)},Et,n=>{let o=e.listen;e.listen=(...l)=>(!e.lc&&!e.active&&(e.active=!0,n()),o(...l));let s=e.off;return e.events[$]=[],e.off=()=>{s(),setTimeout(()=>{if(e.active&&!e.lc){e.active=!1;for(let l of e.events[$])l();e.events[$]=[]}},Q)},()=>{e.listen=o,e.off=s}});function Pe(e){if(typeof e!="object"||e===null)return!1;let t=Object.getPrototypeOf(e);return t===Object.prototype||t===null}function L(e,t){if(e===t)return!0;if(Array.isArray(e)&&Array.isArray(t)){if(e.length!==t.length)return!1;for(let r=0;r<e.length;r++)if(!L(e[r],t[r]))return!1;return!0}if(Pe(e)&&Pe(t)){let r=Object.keys(e),n=Object.keys(t);if(r.length!==n.length)return!1;for(let o of r)if(!(o in t)||!L(e[o],t[o]))return!1;return!0}return!1}function X(e,t){return fe(e,({newValue:r,abort:n})=>{t(e.value,r)&&n()})}var de=Symbol.for("better-auth:focus-manager"),yt=class{listeners=new Set;subscribe(e){return this.listeners.add(e),()=>{this.listeners.delete(e)}}setFocused(e){this.listeners.forEach(t=>t(e))}setup(){if(typeof window>"u"||typeof document>"u"||typeof window.addEventListener>"u")return()=>{};let e=()=>{document.visibilityState==="visible"&&this.setFocused(!0)};return document.addEventListener("visibilitychange",e,!1),()=>{document.removeEventListener("visibilitychange",e,!1)}}};function he(){return globalThis[de]||(globalThis[de]=new yt),globalThis[de]}var pe=Symbol.for("better-auth:online-manager"),gt=class{listeners=new Set;isOnline=typeof navigator<"u"?navigator.onLine:!0;subscribe(e){return this.listeners.add(e),()=>{this.listeners.delete(e)}}setOnline(e){this.isOnline=e,this.listeners.forEach(t=>t(e))}setup(){if(typeof window>"u"||typeof window.addEventListener>"u")return()=>{};let e=()=>this.setOnline(!0),t=()=>this.setOnline(!1);return window.addEventListener("online",e,!1),window.addEventListener("offline",t,!1),()=>{window.removeEventListener("online",e,!1),window.removeEventListener("offline",t,!1)}}};function Z(){return globalThis[pe]||(globalThis[pe]=new gt),globalThis[pe]}var Rt={proto:/"(?:_|\\u0{2}5[Ff]){2}(?:p|\\u0{2}70)(?:r|\\u0{2}72)(?:o|\\u0{2}6[Ff])(?:t|\\u0{2}74)(?:o|\\u0{2}6[Ff])(?:_|\\u0{2}5[Ff]){2}"\s*:/,constructor:/"(?:c|\\u0063)(?:o|\\u006[Ff])(?:n|\\u006[Ee])(?:s|\\u0073)(?:t|\\u0074)(?:r|\\u0072)(?:u|\\u0075)(?:c|\\u0063)(?:t|\\u0074)(?:o|\\u006[Ff])(?:r|\\u0072)"\s*:/,protoShort:/"__proto__"\s*:/,constructorShort:/"constructor"\s*:/},bt=/^\s*["[{]|^\s*-?\d{1,16}(\.\d{1,17})?([Ee][+-]?\d+)?\s*$/,Ue={true:!0,false:!1,null:null,undefined:void 0,nan:NaN,infinity:Number.POSITIVE_INFINITY,"-infinity":Number.NEGATIVE_INFINITY},vt=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,7}))?(?:Z|([+-])(\d{2}):(\d{2}))$/;function Tt(e){return e instanceof Date&&!isNaN(e.getTime())}function St(e){let t=vt.exec(e);iLine truncated
|
||||
at `);return t.length<=1?e:(t.splice(1,1),t.join(`
|
||||
at `))}function Ct(e,t){class r extends e{#e;constructor(...o){if(Lt()){let l=Error.stackTraceLimit;Error.stackTraceLimit=0,super(...o),Error.stackTraceLimit=l}else super(...o);let s=new Error().stack;s&&(this.#e=Nt(s.replace(/^Error/,this.name)))}get errorStack(){return this.#e}}return Object.defineProperty(r.prototype,"constructor",{get(){return t},enumerable:!1,configurable:!0}),r}var Dt={OK:200,CREATED:201,ACCEPTED:202,NO_CONTENT:204,MULTIPLE_CHOICES:300,MOVED_PERMANENTLY:301,FOUND:302,SEE_OTHER:303,NOT_MODIFIED:304,TEMPORARY_REDIRECT:307,BAD_REQUEST:400,UNAUTHORIZED:401,PAYMENT_REQUIRED:402,FORBIDDEN:403,NOT_FOUND:404,METHOD_NOT_ALLOWED:405,NOT_ACCEPTABLE:406,PROXY_AUTHENTICATION_REQUIRED:407,REQUEST_TIMEOUT:408,CONFLICT:409,GONE:410,LENGTH_REQUIRED:411,PRECONDITION_FAILED:412,PAYLOAD_TOO_LARGE:413,URI_TOO_LONG:414,UNSUPPORTED_MEDIA_TYPE:415,RANGE_NOT_SATISFIABLE:416,EXPECTATION_FAILED:417,"I'M_A_TEAPOT":418,MISDIRECTED_REQUEST:421,UNPROCESSABLE_ENTITY:422,LOCKED:423,FAILED_DEPENDENCY:424,TOO_EARLY:425,UPGRADE_REQUIRED:426,PRECONDITION_REQUIRED:428,TOO_MANY_REQUESTS:429,REQUEST_HEADER_FIELDS_TOO_LARGE:431,UNAVAILABLE_FOR_LEGAL_REASONS:451,INTERNAL_SERVER_ERROR:500,NOT_IMPLEMENTED:501,BAD_GATEWAY:502,SERVICE_UNAVAILABLE:503,GATEWAY_TIMEOUT:504,HTTP_VERSION_NOT_SUPPORTED:505,VARIANT_ALSO_NEGOTIATES:506,INSUFFICIENT_STORAGE:507,LOOP_DETECTED:508,NOT_EXTENDED:510,NETWORK_AUTHENTICATION_REQUIRED:511},xt=class extends Error{status;body;headers;statusCode;constructor(e="INTERNAL_SERVER_ERROR",t=void 0,r={},n=typeof e=="number"?e:Dt[e]){if(super(t?.message,t&&"cause"in t?{cause:t.cause}:void 0),this.status=e,this.body=t,this.headers=r,this.statusCode=n,this.name="APIError",this.status=e,this.headers=r,this.statusCode=n,t&&"cause"in t){let{cause:o,...s}=t;this.body=Object.defineProperty(s,"cause",{value:o,writable:!0,configurable:!0});return}this.body=t}};var kt=Ct(xt,Error);var F=class extends Error{constructor(e,t){super(e,t),this.name="BetterAuthError",this.message=e,this.stack=""}};var Mt=47;function De(e){let t=e.length;for(;t>0&&e.charCodeAt(t-1)===Mt;)t--;return t===e.length?e:e.slice(0,t)}function Bt(e){try{return(De(new URL(e).pathname)||"/")!=="/"}catch{throw new F(`Invalid base URL: ${e}. Please provide a valid base URL.`)}}function qt(e){try{let t=new URL(e);if(t.protocol!=="http:"&&t.protocol!=="https:")throw new F(`Invalid base URL: ${e}. URL must include 'http://' or 'https://'`)}catch(t){throw t instanceof F?t:new F(`Invalid base URL: ${e}. Please provide a valid base URL.`,{cause:t})}}function W(e,t="/api/auth"){if(qt(e),Bt(e))return e;let r=De(e);return!t||t==="/"?r:(t=t.startsWith("/")?t:`/${t}`,`${r}${t}`)}function Ce(e,t){return!e||e.trim()===""?!1:t==="proto"?e==="http"||e==="https":t==="host"?[/\.\./,/\0/,/[\s]/,/^[.]/,/[<>'"]/,/javascript:/i,/file:/i,/data:/i].some(r=>r.test(e))?!1:/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*(:[0-9]{1,5})?$/.test(e)||/^(\d{1,3}\.){3}\d{1,3}(:[0-9]{1,5})?$/.test(e)||/^\[[0-9a-fA-F:]+\](:[0-9]{1,5})?$/.test(e)||/^localhost(:[0-9]{1,5})?$/i.test(e):!1}function xe(e,t,r,n,o){if(e)return W(e,t);if(n!==!1){let a=I.BETTER_AUTH_URL||I.NEXT_PUBLIC_BETTER_AUTH_URL||I.PUBLIC_BETTER_AUTH_URL||I.NUXT_PUBLIC_BETTER_AUTH_URL||I.NUXT_PUBLIC_AUTH_URL||(I.BASE_URL!=="/"?I.BASE_URL:void 0);if(a)return W(a,t)}let s=r?.headers.get("x-forwarded-host"),l=r?.headers.get("x-forwarded-proto");if(s&&l&&o&&Ce(l,"proto")&&Ce(s,"host"))try{return W(`${l}://${s}`,t)}catch{}if(r){let a=jt(r.url);if(!a)throw new F("Could not get origin from request. Please provide a valid base URL.");return W(a,t)}if(typeof window<"u"&&window.location)return W(window.location.origin,t)}function jt(e){try{let t=new URL(e);return t.origin==="null"?null:t.origin}catch{return null}}var Ft=["javascript:","data:","vbscript:"];function ke(e){let t;try{t=new URL(e)}catch{return!0}return!Ft.includes(t.protocol)}var Me={id:"redirect",name:"Redirect",hooks:{onSuccess(e){if(e.data?.url&&e.data?.redirect&&ke(e.data.url)&&typeof window<"u"&&window.location&&window.location)try{window.location.href=e.data.url}catch{}}}};var Ht=()=>typeof window>"u",Vt=Q;function qe(e,t){if(typeof window>"u")return;let r=e.get();r.data!==null||t===null||e.set({...r,data:t,error:null,isPending:!1})}function Be(e){return typeof e=="object"&&e!==null&&"data"in e&&"error"in e?e:{data:e,error:null}}function Wt(e){return!e||e.session===null&&e.user===null?null:e}function Gt(e,t){return L(e.data,t.data)&&e.error===t.error&&e.isPending===t.isPending&&e.isRefetching===t.isRefetching&&e.refetch===t.refetch}function je(e,t){let r=D(!1),n,o=0,s=0;r.listen(()=>{s++,o=0});let l=m=>u(m),a=ee({data:null,error:null,isPending:!0,isRefetching:!1,refetch:l});X(a,Gt);let i=async(m,E)=>{let f=a.value;if(a.set({...f,isPending:f.data===null,isRefetching:!0,error:null,refetch:l}),m.aborted)return"aborted";try{let g=await e("/get-session",{method:"GET",query:E?.query,signal:m});if(m.aborted)return"aborted";l
Line truncated
|
||||
`,t)}function it(){if(!M())return Oe.stubThis(new Promise(t=>t(!1)));let e=globalThis.PublicKeyCredential;return e?.isConditionalMediationAvailable===void 0?Oe.stubThis(new Promise(t=>t(!1))):Oe.stubThis(e.isConditionalMediationAvailable())}var Oe={stubThis:e=>e};function at({error:e,options:t}){let{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if(e.name==="AbortError"){if(t.signal instanceof AbortSignal)return new v({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if(e.name==="NotAllowedError")return new v({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if(e.name==="SecurityError"){let n=globalThis.location.hostname;if(ne(n)){if(r.rpId!==n)return new v({message:`The RP ID "${r.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else return new v({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e})}else if(e.name==="UnknownError")return new v({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}async function ut(e){!e.optionsJSON&&e.challenge&&(console.warn("startAuthentication() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});let{optionsJSON:t,useBrowserAutofill:r=!1,verifyBrowserAutofillInput:n=!0}=e;if(!M())throw new Error("WebAuthn is not supported in this browser");let o;t.allowCredentials?.length!==0&&(o=t.allowCredentials?.map(re));let s={...t,challenge:k(t.challenge),allowCredentials:o},l={};if(r){if(!await it())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete$='webauthn']").length<1&&n)throw Error('No <input> with "webauthn" as the only or last value in its `autocomplete` attribute was detected');l.mediation="conditional",s.allowCredentials=[]}l.publicKey=s,l.signal=oe.createNewAbortSignal();let a;try{a=await navigator.credentials.get(l)}catch(m){throw at({error:m,options:l})}if(!a)throw new Error("Authentication was not completed");let{id:i,rawId:c,response:u,type:d}=a,p;return u.userHandle&&(p=P(u.userHandle)),{id:i,rawId:P(c),response:{authenticatorData:P(u.authenticatorData),clientDataJSON:P(u.clientDataJSON),signature:P(u.signature),userHandle:p},type:d,clientExtensionResults:a.getClientExtensionResults(),authenticatorAttachment:se(a.authenticatorAttachment)}}var Lr=(e,{$listPasskeys:t,$store:r})=>({signIn:{passkey:async(s,l)=>{let a=await e("/passkey/generate-authenticate-options",{method:"GET",throw:!1});if(!a.data)return a;let i=a.data.extensions||s?.extensions?{...a.data.extensions||{},...s?.extensions||{}}:void 0,c;try{c=await ut({optionsJSON:{...a.data,extensions:i},useBrowserAutofill:s?.autoFill})}catch(u){return{data:null,error:{code:u instanceof v?u.code:"AUTH_CANCELLED",message:x.AUTH_CANCELLED.message,status:400,statusText:"BAD_REQUEST"}}}try{let{clientExtensionResults:u,...d}=c,p=await e("/passkey/verify-authentication",{body:{response:d},...s?.fetchOptions,...l,method:"POST",throw:!1});return t.set(Math.random()),r.notify("$sessionSignal"),s?.returnWebAuthnResponse?{...p,webauthn:{response:c,clientExtensionResults:u}}:p}catch(u){return console.error("[Better Auth] Error verifying passkey",u),{data:null,error:{code:"AUTH_CANCELLED",message:x.AUTH_CANCELLED.message,status:400,statusText:"BAD_REQUEST"}}}}},passkey:{addPasskey:async(s,l)=>{let a=await e("/passkey/generate-register-options",{method:"GET",query:{...s?.authenticatorAttachment&&{authenticatorAttachment:s.authenticatorAttachment},...s?.name&&{name:s.name},...s?.context&&{context:s.context}},throw:!1});if(!a.data)return a;try{let i=a.data.extensions||s?.extensions?{...a.data.extensions||{},...s?.extensions||{}}:void 0,c=await st({optionsJSON:{...a.data,extensions:i},useAutoRegister:s?.useAutoRegister}),{clientExtensionResults:u,...d}=c,p=await e("/passkey/verify-registration",{...s?.fetchOptions,...l,body:{response:d,name:s?.name,...s?.createSession&&{createSession:!0}},method:"POST",throw:!1});return p.data?(t.set(Math.random()),p.data.session&&r.notify("$sessionSignal"),s?.returnWebAuthnResponse?{...p,webauthn:{response:c,clientExtensionResults:u}}:p):p}catch(i){return i instanceof v?i.code==="ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED"?{data:null,error:{code:i.code,message:x.PREVIOUSLY_REGISTERED.message,status:400,statusText:"BAD_REQUEST"}}:i.code==="ERROR_CEREMONY_ABORTED"?{data:null,error:{code:i.code,message:x.REGISTRATION_CANCELLED.message,status:400,statusText:"BAD_REQUEST"}}:{data:null,error:{code:i.code,message:i.message,status:400,statusText:"BAD_REQUEST"}}:{data:null,error:{code:"UN
Line truncated
|
||||
@@ -390,20 +390,13 @@
|
||||
username: previewUser ? previewUser.getAttribute('data-username') : '',
|
||||
display_name: previewUser ? previewUser.getAttribute('data-display-name') : '',
|
||||
email: previewUser ? previewUser.getAttribute('data-email') : '',
|
||||
verification_expiry: previewUser ? previewUser.getAttribute('data-verification-expiry') : '30',
|
||||
reset_expiry: previewUser ? previewUser.getAttribute('data-reset-expiry') : '30',
|
||||
invitation_expiry: previewUser ? previewUser.getAttribute('data-invitation-expiry') : '24'
|
||||
otp_expiry: previewUser ? previewUser.getAttribute('data-otp-expiry') : '30'
|
||||
};
|
||||
document.querySelectorAll('[data-email-template-editor]').forEach(function (editor) {
|
||||
var subject = editor.querySelector('[data-email-template-subject]');
|
||||
var body = editor.querySelector('[data-email-template-body]');
|
||||
var alignment = editor.querySelector('[data-email-template-alignment]');
|
||||
var buttonText = editor.querySelector('[data-email-template-button-text]');
|
||||
var preview = editor.querySelector('[data-email-template-preview]');
|
||||
if (!subject || !body || !preview) return;
|
||||
var isVerificationTemplate = Boolean(editor.querySelector('[name="verification_subject"]'));
|
||||
var isResetTemplate = Boolean(editor.querySelector('[name="reset_subject"]'));
|
||||
var isInvitationTemplate = Boolean(editor.querySelector('[name="invitation_subject"]'));
|
||||
|
||||
function render() {
|
||||
preview.innerHTML = '';
|
||||
@@ -418,45 +411,16 @@
|
||||
username: signedInUser.username || 'username',
|
||||
display_name: signedInUser.display_name || signedInUser.username || 'Your name',
|
||||
email: signedInUser.email || 'your-email@example.com',
|
||||
expiry_time: isInvitationTemplate ? signedInUser.invitation_expiry + ' hours' : (isResetTemplate ? signedInUser.reset_expiry : signedInUser.verification_expiry) + ' minutes'
|
||||
otp: '123456',
|
||||
expiry_time: signedInUser.otp_expiry + ' minutes'
|
||||
};
|
||||
var button = document.createElement('div');
|
||||
button.className = 'email-template-preview__button';
|
||||
var buttonAlignment = alignment ? alignment.value : 'center';
|
||||
var buttonWrap = document.createElement('div');
|
||||
buttonWrap.className = 'email-template-preview__button-wrap';
|
||||
buttonWrap.style.textAlign = buttonAlignment;
|
||||
buttonWrap.appendChild(button);
|
||||
var previewActionPlaceholder = '[[url]]';
|
||||
button.textContent = buttonText && buttonText.value ? buttonText.value : (isVerificationTemplate ? 'Verify email address' : (isInvitationTemplate ? 'Accept invitation' : 'Reset password'));
|
||||
var previewOrigin = window.location.origin || 'https://pulse-signage.example';
|
||||
var previewVerificationUrl = previewOrigin + '/verify-email?token=preview-token';
|
||||
var previewResetUrl = previewOrigin + '/reset-password?token=preview-token';
|
||||
var previewActionUrl = isVerificationTemplate ? previewVerificationUrl : (isInvitationTemplate ? previewOrigin + '/accept-invite?token=preview-token' : previewResetUrl);
|
||||
var hasExplicitButton = body.value.indexOf('[[action_button]]') !== -1;
|
||||
body.value.split(/\r?\n(?:[ \t]*\r?\n)+/).forEach(function (sourceParagraph) {
|
||||
var line = sourceParagraph.replace(/\[\[(username|display_name|email|expiry_time)\]\]/g, function (_match, key) { return sampleValues[key]; }).replace(previewActionPlaceholder, previewActionUrl);
|
||||
var hasButtonToken = sourceParagraph.indexOf('[[action_button]]') !== -1;
|
||||
var hasUrlToken = !hasExplicitButton && sourceParagraph.indexOf(previewActionPlaceholder) !== -1;
|
||||
if (hasButtonToken) {
|
||||
var buttonParts = line.split('[[action_button]]');
|
||||
if (buttonParts[0]) appendPreviewParagraph(buttonParts[0]);
|
||||
message.appendChild(buttonWrap);
|
||||
if (buttonParts[1]) appendPreviewParagraph(buttonParts[1]);
|
||||
return;
|
||||
}
|
||||
if (hasUrlToken) {
|
||||
var legacyParts = sourceParagraph.split(previewActionPlaceholder);
|
||||
if (legacyParts[0]) appendPreviewParagraph(legacyParts[0]);
|
||||
message.appendChild(buttonWrap);
|
||||
appendPreviewParagraph(previewActionUrl + legacyParts[1]);
|
||||
return;
|
||||
}
|
||||
var line = sourceParagraph.replace(/\[\[(username|display_name|email|expiry_time|otp)\]\]/g, function (_match, key) { return sampleValues[key]; });
|
||||
appendPreviewParagraph(line);
|
||||
});
|
||||
function appendPreviewParagraph(line) {
|
||||
var paragraph = document.createElement('p');
|
||||
paragraph.innerHTML = (line || '\u00a0').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\r?\n/g, '<br>').replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1">$1</a>').replace(/\[b\]([\s\S]*?)\[\/b\]/gi, '<strong>$1</strong>').replace(/\[i\]([\s\S]*?)\[\/i\]/gi, '<em>$1</em>').replace(/\[u\]([\s\S]*?)\[\/u\]/gi, '<u>$1</u>');
|
||||
paragraph.innerHTML = (line || '\u00a0').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\r?\n/g, '<br>').replace(/\[b\]([\s\S]*?)\[\/b\]/gi, '<strong>$1</strong>').replace(/\[i\]([\s\S]*?)\[\/i\]/gi, '<em>$1</em>').replace(/\[u\]([\s\S]*?)\[\/u\]/gi, '<u>$1</u>');
|
||||
message.appendChild(paragraph);
|
||||
}
|
||||
preview.appendChild(brand);
|
||||
@@ -466,8 +430,6 @@
|
||||
|
||||
subject.addEventListener('input', render);
|
||||
body.addEventListener('input', render);
|
||||
if (alignment) alignment.addEventListener('change', render);
|
||||
if (buttonText) buttonText.addEventListener('input', render);
|
||||
editor.querySelectorAll('[data-email-format]').forEach(function (formatButton) {
|
||||
formatButton.addEventListener('click', function () {
|
||||
var tag = formatButton.getAttribute('data-email-format');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Account password page renderer and return URL normalization.
|
||||
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { resolvePasskeyRpID } = require('#src/web/lib/auth/better-auth');
|
||||
|
||||
function buildPasswordRequirementsText(requirements) {
|
||||
const requiredCategories = [];
|
||||
@@ -26,7 +27,7 @@ function normalizeReturnUrl(value) {
|
||||
return url;
|
||||
}
|
||||
|
||||
module.exports = function renderAccountPage(currentUser, message, returnUrl, allowUserSessionRevocation, sessions, passwordRequirements) {
|
||||
module.exports = function renderAccountPage(currentUser, message, returnUrl, allowUserSessionRevocation, sessions, passwordRequirements, authMethods, passkeys) {
|
||||
const requirements = passwordRequirements || {
|
||||
minimumLength: 10,
|
||||
minimumCategories: 3,
|
||||
@@ -45,13 +46,20 @@ module.exports = function renderAccountPage(currentUser, message, returnUrl, all
|
||||
email: currentUser ? String(currentUser.email || '') : '',
|
||||
emailPending: Boolean(currentUser && currentUser.pending_email),
|
||||
pendingEmail: currentUser ? String(currentUser.pending_email || '') : '',
|
||||
emailVerified: Boolean(currentUser && currentUser.email_verified_at),
|
||||
pendingEmailChange: Boolean(currentUser && currentUser.pending_email && String(currentUser.pending_email).trim().toLowerCase() !== String(currentUser.email || '').trim().toLowerCase()),
|
||||
emailVerified: Boolean(currentUser && currentUser.email_verified),
|
||||
authMethods: Array.isArray(authMethods) ? authMethods : [],
|
||||
passkeys: Array.isArray(passkeys) ? passkeys : [],
|
||||
passkeyRpID: resolvePasskeyRpID(process.env.BETTER_AUTH_URL || process.env.WEB_PUBLIC_URL),
|
||||
returnUrl: normalizeReturnUrl(returnUrl),
|
||||
allowUserSessionRevocation: Boolean(allowUserSessionRevocation),
|
||||
sessions: Array.isArray(sessions) ? sessions : [],
|
||||
passwordMinimumLength: requirements.minimumLength,
|
||||
passwordRequirementsText: buildPasswordRequirementsText(requirements),
|
||||
scripts: ['js/account/account-page.js']
|
||||
twoFactorEnabled: Boolean(currentUser && currentUser.twoFactorEnabled),
|
||||
requiresTwoFactor: Boolean(currentUser && currentUser.requiresTwoFactor),
|
||||
canManageTwoFactor: Boolean(currentUser && !currentUser.isViewingAs),
|
||||
scripts: ['vendor/qr-code-styling/qr-code-styling-loader.js', 'js/auth/passkey-client.js', 'js/account/two-factor.js', 'js/account/passkeys.js']
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+149
-89
@@ -1,33 +1,50 @@
|
||||
// Admin account route registration and profile helpers.
|
||||
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
|
||||
const { formatUserAgentLabel } = require('#src/data/audit-log');
|
||||
const { createSyntheticAuthEmail, toVisibleEmail, mapAuthMethod } = require('#src/data/auth-accounts');
|
||||
|
||||
module.exports = function registerAccountRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const betterAuth = deps.betterAuth;
|
||||
const fromNodeHeaders = deps.fromNodeHeaders;
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const verifyPassword = deps.verifyPassword;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const validatePasswordStrength = deps.validatePasswordStrength;
|
||||
const createUserSession = deps.createUserSession;
|
||||
const setSessionCookie = deps.setSessionCookie;
|
||||
const getSessionMaxAgeMs = deps.getSessionMaxAgeMs;
|
||||
const clearSessionCookie = deps.clearSessionCookie;
|
||||
const parseCookies = deps.parseCookies;
|
||||
const hashSessionToken = deps.hashSessionToken;
|
||||
const sessionCookieName = deps.sessionCookieName;
|
||||
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||
const sendAccountEmail = deps.sendAccountEmail;
|
||||
const createOneTimeToken = deps.createOneTimeToken;
|
||||
|
||||
async function verifyCurrentPassword(req, password) {
|
||||
try {
|
||||
const result = await betterAuth.api.verifyPassword({
|
||||
body: { password: password },
|
||||
headers: fromNodeHeaders(req.headers)
|
||||
});
|
||||
return !result || result.status !== false;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getPasswordRequirements() {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
return buildPasswordRequirements(settings);
|
||||
}
|
||||
|
||||
async function getAuthMethods(userId) {
|
||||
const [rows] = await pool.query('SELECT provider_id FROM a_accounts WHERE user_id = ? ORDER BY created_at ASC', [userId]);
|
||||
return (rows || []).map(function (row) { return mapAuthMethod(row.provider_id); }).filter(Boolean);
|
||||
}
|
||||
|
||||
async function getPasskeys(userId) {
|
||||
const [rows] = await pool.query('SELECT id, name, created_at FROM a_user_passkeys WHERE user_id = ? ORDER BY created_at ASC', [userId]);
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
function isPasswordChangeRequired(req) {
|
||||
return Boolean(req && (req.authenticatedUser && req.authenticatedUser.mustChangePassword || req.currentUser && req.currentUser.mustChangePassword));
|
||||
}
|
||||
@@ -51,67 +68,112 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionMetadata(req) {
|
||||
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||
return {
|
||||
ipAddress: forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || 'unknown').trim(),
|
||||
userAgent: req && req.headers && req.headers['user-agent']
|
||||
};
|
||||
}
|
||||
async function clearPendingEmailWithoutActiveOtp(user) {
|
||||
const pendingEmail = String(user && user.pending_email || '').trim().toLowerCase();
|
||||
if (!pendingEmail) return;
|
||||
const email = String(user.email || '').trim().toLowerCase();
|
||||
const identifier = pendingEmail === email
|
||||
? 'email-verification-otp-' + email
|
||||
: 'change-email-otp-' + email + '-' + pendingEmail;
|
||||
|
||||
function getRequestOrigin(req) {
|
||||
const forwardedProto = String(req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim();
|
||||
const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
|
||||
return forwardedHost ? forwardedProto + '://' + forwardedHost : '';
|
||||
await pool.query('DELETE FROM a_verifications WHERE identifier = ? AND expires_at <= NOW()', [identifier]);
|
||||
const [result] = await pool.query(
|
||||
`UPDATE a_users
|
||||
SET pending_email = NULL
|
||||
WHERE id = ?
|
||||
AND pending_email = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM a_verifications
|
||||
WHERE identifier = ? AND expires_at > NOW()
|
||||
)`,
|
||||
[user.id, user.pending_email, identifier]
|
||||
);
|
||||
if (result && result.affectedRows) user.pending_email = null;
|
||||
}
|
||||
|
||||
app.post('/account/email', async function (req, res, next) {
|
||||
try {
|
||||
const email = String(req.body.email || '').trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).send('Email address is invalid.');
|
||||
const [rows] = await pool.query('SELECT pending_email FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const email = String(rows && rows[0] && rows[0].pending_email || '').trim().toLowerCase();
|
||||
if (!email) return res.redirect('/account?message=' + encodeURIComponent('There is no pending email change.'));
|
||||
const settings = await fetchAppSettings(pool);
|
||||
if (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function') return res.status(400).send('Email delivery is not configured.');
|
||||
const token = createOneTimeToken();
|
||||
const expiryMinutes = Number(settings['email.verification_expiry_minutes']);
|
||||
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL ' + expiryMinutes + ' MINUTE) WHERE id = ?', [email, hashSessionToken(token), req.currentUser.id]);
|
||||
const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token);
|
||||
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, expiry_time: formatAccountEmailExpiry(expiryMinutes, 'minutes'), action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] })));
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.email_change_requested', actorUserId: req.currentUser.id, targetType: 'user', targetId: req.currentUser.id, targetLabel: email });
|
||||
res.redirect('/account?message=' + encodeURIComponent('Check your new email address for a verification link.'));
|
||||
if (!settings['email.smtp_enabled']) return res.status(400).send('Email delivery is not configured.');
|
||||
await betterAuth.api.requestEmailChangeEmailOTP({ body: { newEmail: email }, headers: fromNodeHeaders(req.headers) });
|
||||
res.redirect('/account?message=' + encodeURIComponent('A verification code was sent to your new email address.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/account', function (req, res) {
|
||||
fetchAppSettings(pool).then(function (settings) {
|
||||
app.post('/account/email/send-verification', async function (req, res, next) {
|
||||
try {
|
||||
const email = String(req.currentUser.email || '').trim().toLowerCase();
|
||||
if (!email) return res.redirect('/account?message=' + encodeURIComponent('Add an email address before requesting a verification code.'));
|
||||
const settings = await fetchAppSettings(pool);
|
||||
if (!settings['email.smtp_enabled']) return res.status(400).send('Email delivery is not configured.');
|
||||
await pool.query('UPDATE a_users SET pending_email = ? WHERE id = ?', [email, req.currentUser.id]);
|
||||
await betterAuth.api.sendVerificationOTP({ body: { email: email, type: 'email-verification' } });
|
||||
res.redirect('/account?message=' + encodeURIComponent('A verification code was sent to your email address.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/account/email/verify', async function (req, res, next) {
|
||||
try {
|
||||
const otp = String(req.body.otp || '').trim();
|
||||
const [rows] = await pool.query('SELECT email, email_verified, pending_email FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const user = rows && rows[0];
|
||||
if (!user) return res.status(404).send('User not found.');
|
||||
const pendingEmail = String(user.pending_email || '').trim().toLowerCase();
|
||||
if (pendingEmail) {
|
||||
if (pendingEmail === String(user.email || '').trim().toLowerCase()) {
|
||||
await betterAuth.api.verifyEmailOTP({ body: { email: pendingEmail, otp: otp } });
|
||||
} else {
|
||||
await betterAuth.api.changeEmailEmailOTP({ body: { newEmail: pendingEmail, otp: otp }, headers: fromNodeHeaders(req.headers) });
|
||||
}
|
||||
await pool.query('UPDATE a_users SET email = ?, email_verified = 1, pending_email = NULL WHERE id = ?', [pendingEmail, req.currentUser.id]);
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'email.verification_completed', targetType: 'user', targetId: req.currentUser.id, targetLabel: pendingEmail });
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Email address verified.'));
|
||||
}
|
||||
res.status(400).send('No email change is awaiting verification. Request a new code and try again.');
|
||||
} catch (error) {
|
||||
res.status(400).send('The email code is invalid or expired. Request a new code and try again.');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/account', async function (req, res) {
|
||||
try {
|
||||
await clearPendingEmailWithoutActiveOtp(req.currentUser);
|
||||
const settings = await fetchAppSettings(pool);
|
||||
const passwordRequirements = buildPasswordRequirements(settings);
|
||||
const authMethods = await getAuthMethods(req.currentUser.id);
|
||||
const passkeys = await getPasskeys(req.currentUser.id);
|
||||
if (!settings['security.allow_user_session_revocation']) {
|
||||
return res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', false, [], passwordRequirements));
|
||||
return res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', false, [], passwordRequirements, authMethods, passkeys));
|
||||
}
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const tokenHash = cookies[sessionCookieName] ? hashSessionToken(cookies[sessionCookieName]) : '';
|
||||
return pool.query(
|
||||
const sessionToken = String(req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName] || '');
|
||||
const result = await pool.query(
|
||||
'SELECT id, session_hash, ip_address, user_agent, created_at, last_used_at, expires_at FROM a_sessions WHERE user_id = ? AND expires_at > NOW() ORDER BY last_used_at DESC',
|
||||
[req.currentUser.id]
|
||||
).then(function (result) {
|
||||
const rows = result[0] || [];
|
||||
const sessions = rows.map(function (session) {
|
||||
return {
|
||||
id: Number(session.id),
|
||||
isCurrent: Boolean(tokenHash && session.session_hash === tokenHash),
|
||||
ipAddress: String(session.ip_address || 'Unknown'),
|
||||
userAgent: formatUserAgentLabel(session.user_agent) || 'Unknown browser',
|
||||
createdAtLabel: formatDashboardDate(session.created_at),
|
||||
lastUsedAtLabel: formatDashboardDate(session.last_used_at),
|
||||
expiresAtLabel: formatDashboardDate(session.expires_at)
|
||||
};
|
||||
});
|
||||
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', true, sessions, passwordRequirements));
|
||||
);
|
||||
const rows = result[0] || [];
|
||||
const sessions = rows.map(function (session) {
|
||||
return {
|
||||
id: Number(session.id),
|
||||
isCurrent: Boolean(sessionToken && session.session_hash === sessionToken),
|
||||
ipAddress: String(session.ip_address || 'Unknown'),
|
||||
userAgent: formatUserAgentLabel(session.user_agent) || 'Unknown browser',
|
||||
createdAtLabel: formatDashboardDate(session.created_at),
|
||||
lastUsedAtLabel: formatDashboardDate(session.last_used_at),
|
||||
expiresAtLabel: formatDashboardDate(session.expires_at)
|
||||
};
|
||||
});
|
||||
}).catch(function (error) {
|
||||
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard', true, sessions, passwordRequirements, authMethods, passkeys));
|
||||
} catch (error) {
|
||||
res.status(500).send(error.message || 'Unable to load account settings.');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/account/password', async function (req, res, next) {
|
||||
@@ -137,37 +199,39 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).send('Email address is invalid.');
|
||||
if (!currentPassword) return res.status(400).send('Current password is required.');
|
||||
|
||||
const [rows] = await pool.query('SELECT id, username, name, email, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const [rows] = await pool.query('SELECT id, username, name, email, pending_email FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const user = rows[0] || null;
|
||||
if (!user) return res.status(404).send('User not found.');
|
||||
if (!verifyPassword(currentPassword, user)) return res.status(400).send('Current password is incorrect.');
|
||||
if (!(await verifyCurrentPassword(req, currentPassword))) return res.status(400).send('Current password is incorrect.');
|
||||
if (username !== user.username && await common.fetchDuplicateName(pool, 'a_users', username, user.id, 'username')) return res.status(400).send('That username already exists.');
|
||||
if (name !== user.name && await common.fetchDuplicateName(pool, 'a_users', name, user.id)) return res.status(400).send('That name already exists.');
|
||||
|
||||
const confirmedEmail = String(user.email || '').trim().toLowerCase();
|
||||
const confirmedEmail = toVisibleEmail(user.email).trim().toLowerCase();
|
||||
const pendingEmail = String(user.pending_email || '').trim().toLowerCase();
|
||||
const emailChanged = email !== confirmedEmail && email !== pendingEmail;
|
||||
const pendingEmailCleared = Boolean(pendingEmail && email === confirmedEmail);
|
||||
const settings = emailChanged ? await fetchAppSettings(pool) : null;
|
||||
if (emailChanged && email && (!settings['email.smtp_enabled'] || typeof sendAccountEmail !== 'function')) return res.status(400).send('Email delivery is not configured.');
|
||||
if (emailChanged && email && !settings['email.smtp_enabled']) return res.status(400).send('Email delivery is not configured.');
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('UPDATE a_users SET username = ?, name = ?, modified_by = ? WHERE id = ?', [username, name, actorId, user.id]);
|
||||
if (emailChanged || pendingEmailCleared) {
|
||||
if (!email) {
|
||||
await pool.query('UPDATE a_users SET email = NULL, email_verified_at = NULL, pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
|
||||
await pool.query('UPDATE a_users SET email = ?, email_verified = 0, pending_email = NULL WHERE id = ?', [createSyntheticAuthEmail(user.id), user.id]);
|
||||
} else if (pendingEmailCleared) {
|
||||
await pool.query('UPDATE a_users SET pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
|
||||
await pool.query('UPDATE a_users SET pending_email = NULL WHERE id = ?', [user.id]);
|
||||
} else {
|
||||
const token = createOneTimeToken();
|
||||
const expiryMinutes = Number(settings['email.verification_expiry_minutes']);
|
||||
await pool.query('UPDATE a_users SET pending_email = ?, pending_email_token_hash = ?, pending_email_expires_at = DATE_ADD(NOW(), INTERVAL ' + expiryMinutes + ' MINUTE) WHERE id = ?', [email, hashSessionToken(token), user.id]);
|
||||
const url = getRequestOrigin(req) + '/verify-email?token=' + encodeURIComponent(token);
|
||||
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.verification_subject'], settings['email.verification_body'], { url: url, username: req.currentUser.username, display_name: req.currentUser.name, email: email, expiry_time: formatAccountEmailExpiry(expiryMinutes, 'minutes'), action_alignment: settings['email.verification_button_alignment'], action_label: settings['email.verification_button_text'] })));
|
||||
await pool.query('UPDATE a_users SET pending_email = ? WHERE id = ?', [email, user.id]);
|
||||
try {
|
||||
await betterAuth.api.requestEmailChangeEmailOTP({ body: { newEmail: email }, headers: fromNodeHeaders(req.headers) });
|
||||
} catch (error) {
|
||||
await pool.query('UPDATE a_users SET pending_email = NULL WHERE id = ?', [user.id]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (emailChanged && typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: email ? 'user.email_change_requested' : 'user.email_cleared', actorUserId: user.id, targetType: 'user', targetId: user.id, targetLabel: email || user.email, details: { previousEmail: confirmedEmail || null, email: email || null, verificationRequired: Boolean(email) } });
|
||||
res.redirect('/account?message=' + encodeURIComponent(emailChanged && email ? 'Account details updated. Check your new email address for a verification link.' : 'Account details updated.'));
|
||||
res.redirect('/account?message=' + encodeURIComponent(emailChanged && email ? 'Account details updated. A verification code was sent to your new email address.' : 'Account details updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -184,13 +248,13 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
|
||||
}
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
const token = req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName];
|
||||
if (!token) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Current session could not be identified.'));
|
||||
}
|
||||
const [result] = await pool.query(
|
||||
'DELETE FROM a_sessions WHERE id = ? AND user_id = ? AND session_hash <> ?',
|
||||
[sessionId, req.currentUser.id, hashSessionToken(token)]
|
||||
[sessionId, req.currentUser.id, token]
|
||||
);
|
||||
if (result && result.affectedRows && typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
@@ -216,9 +280,9 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Signing out other sessions is disabled by an administrator.'));
|
||||
}
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
const token = req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName];
|
||||
if (token) {
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ? AND session_hash <> ?', [req.currentUser.id, hashSessionToken(token)]);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ? AND session_hash <> ?', [req.currentUser.id, token]);
|
||||
}
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
@@ -266,10 +330,10 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
const currentPassword = String(req.body.current_password || '');
|
||||
if (!username) return res.status(400).send('Username is required.');
|
||||
if (!currentPassword) return res.status(400).send('Current password is required.');
|
||||
const [rows] = await pool.query('SELECT id, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const [rows] = await pool.query('SELECT id, username FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const user = rows[0] || null;
|
||||
if (!user) return res.status(404).send('User not found.');
|
||||
if (!verifyPassword(currentPassword, user)) return res.status(400).send('Current password is incorrect.');
|
||||
if (!(await verifyCurrentPassword(req, currentPassword))) return res.status(400).send('Current password is incorrect.');
|
||||
const [existingRows] = await pool.query('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1', [username, user.id]);
|
||||
if (existingRows.length) return res.status(400).send('That username already exists.');
|
||||
await pool.query('UPDATE a_users SET username = ?, modified_by = ? WHERE id = ?', [username, getAuditUserId(req), user.id]);
|
||||
@@ -285,13 +349,7 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
const currentPassword = String(req.body.current_password || '');
|
||||
const newPassword = String(req.body.new_password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
|
||||
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1', [req.currentUser.id]);
|
||||
const user = rows[0] || null;
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
if (!verifyPassword(currentPassword, user)) {
|
||||
if (!currentPassword) {
|
||||
return sendPasswordError(req, res, 'Current password is incorrect.');
|
||||
}
|
||||
const passwordStrengthMessage = validatePasswordStrength(newPassword, await getPasswordRequirements());
|
||||
@@ -302,28 +360,30 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
return sendPasswordError(req, res, 'New passwords do not match.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(newPassword);
|
||||
await pool.query(
|
||||
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = 0, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), user.id]
|
||||
);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [user.id]);
|
||||
try {
|
||||
await betterAuth.api.changePassword({
|
||||
body: { currentPassword: currentPassword, newPassword: newPassword, revokeOtherSessions: true },
|
||||
headers: fromNodeHeaders(req.headers)
|
||||
});
|
||||
} catch (_error) {
|
||||
return sendPasswordError(req, res, 'Current password is incorrect.');
|
||||
}
|
||||
await pool.query('UPDATE a_users SET must_change_password = 0, modified_by = ? WHERE id = ?', [getAuditUserId(req), req.currentUser.id]);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [req.currentUser.id]);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'security',
|
||||
eventType: 'password.changed',
|
||||
actorUserId: user.id,
|
||||
actorUserId: req.currentUser.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
targetLabel: user.username,
|
||||
targetId: req.currentUser.id,
|
||||
targetLabel: req.currentUser.username,
|
||||
details: { source: 'account' }
|
||||
});
|
||||
}
|
||||
|
||||
const sessionMaxAgeMs = typeof getSessionMaxAgeMs === 'function' ? await getSessionMaxAgeMs() : undefined;
|
||||
const token = await createUserSession(pool, user.id, sessionMaxAgeMs, getSessionMetadata(req));
|
||||
setSessionCookie(res, token, sessionMaxAgeMs);
|
||||
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
|
||||
if (typeof clearSessionCookie === 'function') clearSessionCookie(res);
|
||||
res.redirect('/login?message=' + encodeURIComponent('Password updated. Sign in again.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const { loadFontLibrary } = require('#src/web/lib/media/font-library');
|
||||
const { readWeatherIconSets } = require('#src/web/lib/media/weather-icon-library');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { convertWeatherSnapshot } = require('#src/data/weather-units');
|
||||
const { buildAuditChanges } = require('#src/data/audit-log');
|
||||
const { buildAuditChanges, buildSlideAuditChanges } = require('#src/data/audit-log');
|
||||
const { replaceContentReferences } = require('#src/data/content-references');
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('#src/data/templates');
|
||||
@@ -644,7 +644,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}
|
||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||
const actorId = getAuditUserId(req);
|
||||
const changes = buildAuditChanges({
|
||||
const changes = buildSlideAuditChanges({
|
||||
title: slide.title,
|
||||
templateId: Number(slide.template_id),
|
||||
content: slide.content
|
||||
@@ -683,7 +683,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
screenSlideCounts: screenSlideCounts
|
||||
});
|
||||
await broadcastDashboardState();
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title, details: { changes: changes } });
|
||||
if (Object.keys(changes).length && typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'slides', eventType: 'slide.updated', actorUserId: actorId, targetType: 'slide', targetId: slide.id, targetLabel: payload.title, details: { changes: changes } });
|
||||
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
|
||||
console.warn('Unable to queue slide thumbnail refresh:', error);
|
||||
});
|
||||
@@ -921,7 +921,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
details: { changes: buildAuditChanges(
|
||||
{ regions: normalizeTemplateRegionsForAudit(template.regions) },
|
||||
{ regions: normalizeTemplateRegionsForAudit(regions) }
|
||||
) }
|
||||
), ...(slideId ? { promotedSlideId: slideId } : {}) }
|
||||
});
|
||||
res.json({ template: Object.assign({}, template, { regions: regions }), slideCount: slideCount });
|
||||
} catch (error) {
|
||||
@@ -1005,7 +1005,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, {
|
||||
category: 'templates', eventType: 'template.updated', actorUserId: actorId,
|
||||
targetType: 'template', targetId: templateId, targetLabel: template.name,
|
||||
details: { changes: changes }
|
||||
details: { changes: changes, ...(slideId ? { promotedSlideId: slideId } : {}) }
|
||||
});
|
||||
res.json({ template: Object.assign({}, template, { regions: regions }), slideCount: slideCount });
|
||||
} catch (error) {
|
||||
@@ -1047,12 +1047,14 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
canvasSizeId: Number(template.canvas_size_id),
|
||||
backgroundImagePath: template.background_image_path,
|
||||
backgroundColor: template.background_color,
|
||||
backgroundGradient: template.background_gradient,
|
||||
regions: normalizeTemplateRegionsForAudit(template.regions)
|
||||
}, {
|
||||
name: payload.name,
|
||||
canvasSizeId: Number(payload.canvasSizeId),
|
||||
backgroundImagePath: payload.backgroundImagePath,
|
||||
backgroundColor: payload.backgroundColor,
|
||||
backgroundGradient: payload.backgroundGradient,
|
||||
regions: normalizeTemplateRegionsForAudit(payload.regions)
|
||||
});
|
||||
connection = await pool.getConnection();
|
||||
|
||||
@@ -19,6 +19,33 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
||||
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
async function fetchPlaylistAuditItems(connection, playlistId) {
|
||||
const [items] = await connection.query(
|
||||
`SELECT id, slide_id, duration_seconds, use_video_duration, disable_audio,
|
||||
group_key, group_name, group_pick_count, group_shuffle, group_mode,
|
||||
group_repeat_count, group_schedule_rules_json
|
||||
FROM c_playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC`,
|
||||
[playlistId]
|
||||
);
|
||||
const [rules] = await connection.query(
|
||||
`SELECT r.playlist_slide_id, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json
|
||||
FROM c_playlist_slide_schedule_rules r
|
||||
INNER JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
|
||||
WHERE ps.playlist_id = ? ORDER BY r.position ASC, r.id ASC`,
|
||||
[playlistId]
|
||||
);
|
||||
const rulesByItem = new Map();
|
||||
rules.forEach(function ({ playlist_slide_id: itemId, ...rule }) {
|
||||
if (!rulesByItem.has(Number(itemId))) rulesByItem.set(Number(itemId), []);
|
||||
if (typeof rule.schedule_days_json === 'string') rule.schedule_days_json = JSON.parse(rule.schedule_days_json);
|
||||
rulesByItem.get(Number(itemId)).push(rule);
|
||||
});
|
||||
return items.map(function ({ id, ...item }) {
|
||||
if (typeof item.group_schedule_rules_json === 'string') item.group_schedule_rules_json = JSON.parse(item.group_schedule_rules_json);
|
||||
return Object.assign(item, { scheduleRules: rulesByItem.get(Number(id)) || [] });
|
||||
});
|
||||
}
|
||||
|
||||
function readRawFieldArray(reqBody, keys) {
|
||||
const searchKeys = Array.isArray(keys) ? keys : [keys];
|
||||
for (let index = 0; index < searchKeys.length; index += 1) {
|
||||
@@ -456,7 +483,9 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
||||
}
|
||||
}
|
||||
|
||||
const previousItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : [];
|
||||
const saveResult = await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: true });
|
||||
const nextItems = typeof recordRequestAuditEvent === 'function' ? await fetchPlaylistAuditItems(connection, playlist.id) : [];
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
|
||||
await broadcastDashboardState();
|
||||
@@ -465,12 +494,14 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
||||
name: playlist.name,
|
||||
fadeBetweenSlides: Boolean(playlist.fade_between_slides),
|
||||
skipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
|
||||
canvasId: playlist.canvas_id === null ? null : Number(playlist.canvas_id)
|
||||
canvasId: playlist.canvas_id === null ? null : Number(playlist.canvas_id),
|
||||
items: previousItems
|
||||
}, {
|
||||
name: name,
|
||||
fadeBetweenSlides: Boolean(fadeBetweenSlides),
|
||||
skipUnavailableRtmp: Boolean(skipUnavailableRtmp),
|
||||
canvasId: saveResult.nextCanvasId === null ? null : Number(saveResult.nextCanvasId)
|
||||
canvasId: saveResult.nextCanvasId === null ? null : Number(saveResult.nextCanvasId),
|
||||
items: nextItems
|
||||
});
|
||||
await recordRequestAuditEvent(pool, req, { category: 'playlists', eventType: 'playlist.updated', actorUserId: actorId, targetType: 'playlist', targetId: playlist.id, targetLabel: name, details: { changes: changes } });
|
||||
}
|
||||
|
||||
@@ -167,7 +167,8 @@
|
||||
return {
|
||||
formValues: {
|
||||
name: common.validateMaxLength(formValues && formValues.name || '', ROLE_NAME_MAX_LENGTH, 'Role name'),
|
||||
description: common.validateMaxLength(formValues && formValues.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description')
|
||||
description: common.validateMaxLength(formValues && formValues.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description'),
|
||||
require_two_factor: Boolean(formValues && formValues.require_two_factor)
|
||||
},
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
|
||||
users: mapUsersForView(data.users, selectedUserIds),
|
||||
@@ -247,7 +248,8 @@
|
||||
const permissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId);
|
||||
const viewModel = await buildRoleCreateViewModel({
|
||||
name: buildDuplicateRoleName(role.name),
|
||||
description: buildDuplicateRole(role).description
|
||||
description: buildDuplicateRole(role).description,
|
||||
require_two_factor: buildDuplicateRole(role).require_two_factor
|
||||
}, permissionKeys, [], page, search, sort, direction, currentUserId);
|
||||
|
||||
res.send(pages.renderRbacAddPage('Review the copied values and save when ready.', req.currentUser, viewModel.formValues, viewModel.permissionGroups, viewModel.users, viewModel.pagination, 'primary'));
|
||||
@@ -260,6 +262,7 @@
|
||||
try {
|
||||
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name');
|
||||
const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description');
|
||||
const requireTwoFactor = req.body.require_two_factor === '1';
|
||||
const selectedUserIds = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present')
|
||||
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
|
||||
: [];
|
||||
@@ -277,7 +280,7 @@
|
||||
const sort = common.getSortQuery(req);
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
|
||||
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId);
|
||||
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description, require_two_factor: requireTwoFactor }, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId);
|
||||
if (!name) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
|
||||
}
|
||||
@@ -308,8 +311,8 @@
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO a_roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[roleKey, name, description || null, actorId, actorId]
|
||||
'INSERT INTO a_roles (role_key, name, description, require_two_factor, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[roleKey, name, description || null, requireTwoFactor ? 1 : 0, actorId, actorId]
|
||||
);
|
||||
insertedRoleId = Number(result.insertId);
|
||||
if (selectedPermissionKeys.length) {
|
||||
@@ -331,7 +334,7 @@
|
||||
targetType: 'role',
|
||||
targetId: insertedRoleId,
|
||||
targetLabel: name,
|
||||
details: { permissionKeys: selectedPermissionKeys, userIds: normalizedUserIds }
|
||||
details: { permissionKeys: selectedPermissionKeys, userIds: normalizedUserIds, requireTwoFactor: requireTwoFactor }
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/roles/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.'));
|
||||
@@ -383,6 +386,7 @@
|
||||
|
||||
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name');
|
||||
const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description');
|
||||
const requireTwoFactor = req.body.require_two_factor === '1';
|
||||
const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present');
|
||||
const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present');
|
||||
const selectedPermissionKeys = shouldSyncPermissions
|
||||
@@ -440,8 +444,8 @@
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE a_roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
|
||||
[name, description || null, getAuditUserId(req), roleId]
|
||||
'UPDATE a_roles SET name = ?, description = ?, require_two_factor = ?, modified_by = ? WHERE id = ?',
|
||||
[name, description || null, requireTwoFactor ? 1 : 0, getAuditUserId(req), roleId]
|
||||
);
|
||||
if (shouldSyncPermissions) {
|
||||
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
|
||||
@@ -460,11 +464,13 @@
|
||||
const changes = buildAuditChanges({
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
requireTwoFactor: Boolean(role.require_two_factor),
|
||||
permissionKeys: existingRolePermissionKeys,
|
||||
userIds: existingRoleUserIds
|
||||
}, {
|
||||
name: name,
|
||||
description: description || null,
|
||||
requireTwoFactor: requireTwoFactor,
|
||||
permissionKeys: normalizedPermissionKeys,
|
||||
userIds: normalizedUserIds
|
||||
});
|
||||
|
||||
+119
-171
@@ -1,8 +1,8 @@
|
||||
// Admin user route registration and user-role management.
|
||||
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
|
||||
const { buildAuditChanges, formatUserAgentLabel } = require('#src/data/audit-log');
|
||||
const { createSyntheticAuthEmail, toVisibleEmail, hashCredentialPassword, setCredentialPassword } = require('#src/data/auth-accounts');
|
||||
|
||||
module.exports = function registerUsersRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -11,11 +11,6 @@
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||
const sendAccountEmail = deps.sendAccountEmail;
|
||||
const createOneTimeToken = deps.createOneTimeToken;
|
||||
const hashSessionToken = deps.hashSessionToken;
|
||||
const getRequestOrigin = deps.getRequestOrigin;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const validatePasswordStrength = deps.validatePasswordStrength;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const rbacData = deps.rbacData;
|
||||
@@ -34,11 +29,6 @@
|
||||
return rbacData.fetchRoles(pool);
|
||||
}
|
||||
|
||||
async function shouldRequirePasswordChange(settingKey) {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
return Boolean(settings[settingKey]);
|
||||
}
|
||||
|
||||
async function getPasswordRequirements() {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
return {
|
||||
@@ -85,23 +75,6 @@
|
||||
return { ok: true, roleIds: normalizedRoleIds };
|
||||
}
|
||||
|
||||
function mapInvitationForView(invitation, roleNamesById) {
|
||||
let roleIds = [];
|
||||
try {
|
||||
roleIds = JSON.parse(invitation.role_ids_json || '[]');
|
||||
} catch (error) {
|
||||
roleIds = [];
|
||||
}
|
||||
return Object.assign({}, invitation, {
|
||||
roleNames: roleIds.map(function (roleId) {
|
||||
return roleNamesById.get(Number(roleId));
|
||||
}).filter(Boolean).join(', ') || 'No roles assigned',
|
||||
createdAtLabel: formatDashboardDate(invitation.created_at),
|
||||
expiresAtLabel: formatDashboardDate(invitation.expires_at),
|
||||
createdByLabel: invitation.created_by_username || 'System'
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/settings/users', requirePermission('users.read'), async function (req, res, next) {
|
||||
try {
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
@@ -126,77 +99,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/settings/invitations', requirePermission('invitations.read'), async function (req, res, next) {
|
||||
try {
|
||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||
const search = common.getSearchQuery(req);
|
||||
const sort = common.getSortQuery(req);
|
||||
const direction = common.getSortDirectionQuery(req);
|
||||
const data = await rbacData.fetchInvitationsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const roles = await fetchRoleOptions();
|
||||
const roleNamesById = new Map((roles || []).map(function (role) {
|
||||
return [Number(role.id), role.name];
|
||||
}));
|
||||
const invitations = (data.invitations || []).map(function (invitation) {
|
||||
return mapInvitationForView(invitation, roleNamesById);
|
||||
});
|
||||
res.send(pages.renderInvitationsPage({
|
||||
invitations: invitations,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'invitations', 'Invitation pages'),
|
||||
message: req.query.message ? String(req.query.message) : ''
|
||||
}, req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/invitations/:id/delete', requirePermission('invitations.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const invitationId = Number(req.params.id);
|
||||
if (!Number.isInteger(invitationId) || invitationId <= 0) {
|
||||
return res.status(400).send('Invalid invitation.');
|
||||
}
|
||||
const [result] = await pool.query('DELETE FROM a_user_invitations WHERE id = ? AND used_at IS NULL', [invitationId]);
|
||||
if (!result || !result.affectedRows) {
|
||||
return res.status(404).send('Invitation not found.');
|
||||
}
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_deleted', actorUserId: getAuditUserId(req), targetType: 'invitation', targetId: invitationId });
|
||||
res.redirect('/settings/invitations?message=' + encodeURIComponent('Invitation deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/invitations/:id/resend', requirePermission('invitations.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const invitationId = Number(req.params.id);
|
||||
if (!Number.isInteger(invitationId) || invitationId <= 0) {
|
||||
return res.status(400).send('Invalid invitation.');
|
||||
}
|
||||
if (typeof sendAccountEmail !== 'function' || typeof createOneTimeToken !== 'function' || typeof hashSessionToken !== 'function') {
|
||||
return res.status(503).send('Email delivery is not available.');
|
||||
}
|
||||
const settings = await fetchAppSettings(pool);
|
||||
if (!settings['email.smtp_enabled']) {
|
||||
return res.status(503).send('Email delivery is not configured.');
|
||||
}
|
||||
const [rows] = await pool.query('SELECT email, name, role_ids_json FROM a_user_invitations WHERE id = ? AND used_at IS NULL AND expires_at > NOW() LIMIT 1', [invitationId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('Invitation not found.');
|
||||
}
|
||||
const invitation = rows[0];
|
||||
const token = createOneTimeToken();
|
||||
const invitationUrl = getRequestOrigin(req) + '/accept-invite?token=' + encodeURIComponent(token);
|
||||
const expiryHours = Number(settings['email.invitation_expiry_hours']);
|
||||
await sendAccountEmail(settings, Object.assign({ to: invitation.email }, renderAccountEmailTemplate(settings['email.invitation_subject'], settings['email.invitation_body'], { url: invitationUrl, username: '', display_name: invitation.name || 'there', email: invitation.email, expiry_time: formatAccountEmailExpiry(expiryHours, 'hours'), action_alignment: settings['email.invitation_button_alignment'], action_label: settings['email.invitation_button_text'] })));
|
||||
await pool.query('UPDATE a_user_invitations SET token_hash = ?, expires_at = DATE_ADD(NOW(), INTERVAL ' + expiryHours + ' HOUR), created_at = NOW(), created_by = ? WHERE id = ? AND used_at IS NULL', [hashSessionToken(token), getAuditUserId(req), invitationId]);
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_resent', actorUserId: getAuditUserId(req), targetType: 'invitation', targetId: invitationId, targetLabel: invitation.email });
|
||||
res.redirect('/settings/invitations?message=' + encodeURIComponent('Invitation resent.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/settings/users/new', requirePermission('users.create'), function (req, res) {
|
||||
fetchRoleOptions().then(function (roles) {
|
||||
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
|
||||
@@ -205,54 +107,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/settings/users/invite', requirePermission('invitations.create'), async function (req, res, next) {
|
||||
try {
|
||||
const roles = await fetchRoleOptions();
|
||||
res.send(pages.renderUsersInvitePage('', req.currentUser, mapRolesForForm(roles, []), {}, 'success'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/users/invite', requirePermission('invitations.create'), async function (req, res, next) {
|
||||
try {
|
||||
const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase();
|
||||
const name = common.validateMaxLength(req.body.name || '', USER_NAME_MAX_LENGTH, 'Name');
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
const formValues = { email: email, name: name };
|
||||
async function renderInviteError(message) {
|
||||
const roles = await fetchRoleOptions();
|
||||
return res.status(400).send(pages.renderUsersInvitePage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning'));
|
||||
}
|
||||
if (!email || !EMAIL_PATTERN.test(email)) return renderInviteError('Email address is invalid.');
|
||||
if (!name) return renderInviteError('Display name is required.');
|
||||
if (!roleCheck.ok) return renderInviteError(roleCheck.message);
|
||||
if (typeof sendAccountEmail !== 'function' || typeof createOneTimeToken !== 'function' || typeof hashSessionToken !== 'function') return renderInviteError('Email delivery is not available.');
|
||||
const settings = await fetchAppSettings(pool);
|
||||
if (!settings['email.smtp_enabled']) return renderInviteError('Email delivery is not configured.');
|
||||
const [existingUsers] = await pool.query('SELECT id FROM a_users WHERE email = ? OR pending_email = ? LIMIT 1', [email, email]);
|
||||
if (existingUsers.length) return renderInviteError('That email address is already associated with an account.');
|
||||
const [recentInvites] = await pool.query('SELECT COUNT(*) AS invite_count FROM a_user_invitations WHERE created_by = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)', [getAuditUserId(req)]);
|
||||
if (Number(recentInvites[0] && recentInvites[0].invite_count) >= 25) return renderInviteError('Invitation sending is temporarily limited. Try again later.');
|
||||
const token = createOneTimeToken();
|
||||
await pool.query('UPDATE a_user_invitations SET used_at = NOW() WHERE email = ? AND used_at IS NULL', [email]);
|
||||
const expiryHours = Number(settings['email.invitation_expiry_hours']);
|
||||
await pool.query('INSERT INTO a_user_invitations (email, name, role_ids_json, token_hash, expires_at, created_by) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ' + expiryHours + ' HOUR), ?)', [email, name || null, JSON.stringify(roleCheck.roleIds), hashSessionToken(token), getAuditUserId(req)]);
|
||||
const invitationUrl = getRequestOrigin(req) + '/accept-invite?token=' + encodeURIComponent(token);
|
||||
try {
|
||||
await sendAccountEmail(settings, Object.assign({ to: email }, renderAccountEmailTemplate(settings['email.invitation_subject'], settings['email.invitation_body'], { url: invitationUrl, username: '', display_name: name || 'there', email: email, expiry_time: formatAccountEmailExpiry(expiryHours, 'hours'), action_alignment: settings['email.invitation_button_alignment'], action_label: settings['email.invitation_button_text'] })));
|
||||
} catch (mailError) {
|
||||
await pool.query('DELETE FROM a_user_invitations WHERE token_hash = ?', [hashSessionToken(token)]);
|
||||
throw mailError;
|
||||
}
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_sent', actorUserId: getAuditUserId(req), targetType: 'email', targetLabel: email, details: { roleIds: roleCheck.roleIds } });
|
||||
res.redirect('/settings/users?message=' + encodeURIComponent('Invitation sent.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/settings/users/:id/duplicate', requirePermission('users.read'), requirePermission('users.create'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
@@ -360,6 +214,91 @@
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/users/:id/two-factor/unlink', requirePermission('users.update'), async function (req, res, next) {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return res.status(400).send('Invalid user.');
|
||||
if (Number(req.currentUser.id) === userId) return res.redirect('/account?message=' + encodeURIComponent('Use My Account to manage your own two-step verification.'));
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [userRows] = await connection.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!userRows.length) {
|
||||
await connection.rollback();
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
const [factorRows] = await connection.query('SELECT id FROM a_user_two_factors WHERE user_id = ? LIMIT 1', [userId]);
|
||||
if (!factorRows.length) {
|
||||
await connection.rollback();
|
||||
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('No authenticator is linked to this user.'));
|
||||
}
|
||||
|
||||
await connection.query('DELETE FROM a_user_two_factors WHERE user_id = ?', [userId]);
|
||||
await connection.query('UPDATE a_users SET two_factor_enabled = 0, modified_by = ? WHERE id = ?', [getAuditUserId(req), userId]);
|
||||
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
await connection.commit();
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'security',
|
||||
eventType: 'user.two_factor_unlinked',
|
||||
actorUserId: getAuditUserId(req),
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
targetLabel: String(userId)
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Two-step verification removed. The user must sign in again.'));
|
||||
} catch (error) {
|
||||
try { await connection.rollback(); } catch (_rollbackError) {}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/users/:id/passkeys/:passkeyId/unlink', requirePermission('users.update'), async function (req, res, next) {
|
||||
const userId = Number(req.params.id);
|
||||
const passkeyId = String(req.params.passkeyId || '').trim();
|
||||
if (!Number.isInteger(userId) || userId <= 0 || !passkeyId || passkeyId.length > 191) return res.status(400).send('Invalid passkey.');
|
||||
if (Number(req.currentUser.id) === userId) return res.redirect('/account?message=' + encodeURIComponent('Use My Account to manage your own passkeys.'));
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [userRows] = await connection.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!userRows.length) {
|
||||
await connection.rollback();
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
const [passkeyRows] = await connection.query('SELECT id FROM a_user_passkeys WHERE id = ? AND user_id = ? LIMIT 1', [passkeyId, userId]);
|
||||
if (!passkeyRows.length) {
|
||||
await connection.rollback();
|
||||
return res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Passkey was not found for this user.'));
|
||||
}
|
||||
|
||||
await connection.query('DELETE FROM a_user_passkeys WHERE id = ? AND user_id = ?', [passkeyId, userId]);
|
||||
await connection.query('UPDATE a_users SET modified_by = ? WHERE id = ?', [getAuditUserId(req), userId]);
|
||||
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
await connection.commit();
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'security',
|
||||
eventType: 'user.passkey_unlinked',
|
||||
actorUserId: getAuditUserId(req),
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
targetLabel: String(userId)
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/users/' + userId + '/edit?message=' + encodeURIComponent('Passkey removed. The user must sign in again.'));
|
||||
} catch (error) {
|
||||
try { await connection.rollback(); } catch (_rollbackError) {}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/users', requirePermission('users.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
@@ -368,13 +307,15 @@
|
||||
const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase();
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const mustChangePassword = req.body.must_change_password === '1' || (Array.isArray(req.body.must_change_password) && req.body.must_change_password.includes('1'));
|
||||
const saveAction = String(req.body.save_action || req.body.action || '').trim().toLowerCase();
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
const formValues = {
|
||||
username: username,
|
||||
name: name,
|
||||
email: email
|
||||
email: email,
|
||||
must_change_password: mustChangePassword
|
||||
};
|
||||
|
||||
async function renderValidationError(message) {
|
||||
@@ -410,14 +351,14 @@
|
||||
return renderValidationError('That username already exists.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_for_new_users');
|
||||
const encodedPassword = await hashCredentialPassword(password);
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO a_users (name, username, email, password_hash, password_salt, password_iterations, must_change_password, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[name, username, email || null, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, actorId, actorId]
|
||||
'INSERT INTO a_users (name, username, email, must_change_password, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[name, username, email || createSyntheticAuthEmail(), mustChangePassword ? 1 : 0, actorId, actorId]
|
||||
);
|
||||
await setCredentialPassword(connection, result.insertId, encodedPassword);
|
||||
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
||||
await connection.commit();
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
@@ -515,6 +456,8 @@
|
||||
const email = common.validateMaxLength(req.body.email || '', USER_EMAIL_MAX_LENGTH, 'Email').toLowerCase();
|
||||
const accountLocked = req.body.account_locked === '1' || (Array.isArray(req.body.account_locked) && req.body.account_locked.includes('1'));
|
||||
const emailVerified = req.body.email_verified === '1' || (Array.isArray(req.body.email_verified) && req.body.email_verified.includes('1'));
|
||||
const mustChangePassword = req.body.must_change_password === '1' || (Array.isArray(req.body.must_change_password) && req.body.must_change_password.includes('1'));
|
||||
const storedEmail = email || createSyntheticAuthEmail(userId);
|
||||
|
||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
|
||||
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
|
||||
@@ -525,6 +468,7 @@
|
||||
return res.status(400).send(pages.renderUsersEditPage(Object.assign({}, user, {
|
||||
name: name || user.name,
|
||||
username: username || user.username,
|
||||
must_change_password: mustChangePassword,
|
||||
roleIds: selectedRoleIds,
|
||||
inUse: !canDelete
|
||||
}), message, req.currentUser, mapRolesForForm(roles, selectedRoleIds)));
|
||||
@@ -559,6 +503,10 @@
|
||||
if (existingRows.length) {
|
||||
return renderValidationError('That username already exists.');
|
||||
}
|
||||
if (email) {
|
||||
const [existingEmailRows] = await pool.query('SELECT id FROM a_users WHERE email = ? AND id <> ? LIMIT 1', [email, userId]);
|
||||
if (existingEmailRows.length) return renderValidationError('That email address is already associated with another account.');
|
||||
}
|
||||
|
||||
const changes = buildAuditChanges({
|
||||
name: user.name,
|
||||
@@ -566,6 +514,7 @@
|
||||
email: user.email || '',
|
||||
roleIds: user.roleIds,
|
||||
accountLocked: Boolean(user.account_locked),
|
||||
mustChangePassword: Boolean(user.must_change_password),
|
||||
passwordReset: false
|
||||
}, {
|
||||
name: name,
|
||||
@@ -573,29 +522,28 @@
|
||||
email: email,
|
||||
roleIds: roleCheck.roleIds,
|
||||
accountLocked: accountLocked,
|
||||
mustChangePassword: mustChangePassword,
|
||||
passwordReset: shouldUpdatePassword
|
||||
});
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, getAuditUserId(req), userId]);
|
||||
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, account_locked = ?, must_change_password = ?, modified_by = ? WHERE id = ?', [name, username, accountLocked ? 1 : 0, mustChangePassword ? 1 : 0, getAuditUserId(req), userId]);
|
||||
if (!result.affectedRows) {
|
||||
await connection.rollback();
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
await connection.query('UPDATE a_users SET email = ?, email_verified_at = CASE WHEN email = ? THEN email_verified_at ELSE NULL END, modified_by = ? WHERE id = ?', [email || null, email || null, getAuditUserId(req), userId]);
|
||||
if (emailVerified && email && (await fetchAppSettings(pool))['security.allow_admin_email_verification_bypass']) {
|
||||
await connection.query('UPDATE a_users SET email_verified_at = NOW() WHERE id = ? AND email = ?', [userId, email]);
|
||||
}
|
||||
const emailIsUnchanged = Boolean(email && email === user.email);
|
||||
let verifiedEmail = Boolean(emailIsUnchanged && user.email_verified);
|
||||
if (emailVerified && email && (await fetchAppSettings(pool))['security.allow_admin_email_verification_bypass']) verifiedEmail = true;
|
||||
if (!email) verifiedEmail = false;
|
||||
await connection.query('UPDATE a_users SET email = ?, email_verified = ?, modified_by = ? WHERE id = ?', [storedEmail, verifiedEmail ? 1 : 0, getAuditUserId(req), userId]);
|
||||
await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds);
|
||||
if (accountLocked) {
|
||||
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
}
|
||||
if (shouldUpdatePassword) {
|
||||
const passwordRecord = hashPassword(password);
|
||||
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset');
|
||||
await connection.query(
|
||||
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, getAuditUserId(req), userId]
|
||||
);
|
||||
const encodedPassword = await hashCredentialPassword(password);
|
||||
await setCredentialPassword(connection, userId, encodedPassword);
|
||||
await connection.query('UPDATE a_users SET must_change_password = ?, modified_by = ? WHERE id = ?', [mustChangePassword ? 1 : 0, getAuditUserId(req), userId]);
|
||||
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
}
|
||||
await connection.commit();
|
||||
@@ -630,7 +578,7 @@
|
||||
details: { source: 'administrator' }
|
||||
});
|
||||
}
|
||||
if (emailVerified && !user.email_verified_at && email) {
|
||||
if (emailVerified && !user.email_verified && verifiedEmail && email) {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'security',
|
||||
eventType: 'email.verification_bypassed',
|
||||
@@ -679,17 +627,17 @@
|
||||
return res.redirect(editUrl + '?message=' + encodeURIComponent('Passwords do not match.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
const [rows] = await pool.query('SELECT id, username, must_change_password FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
const mustChangePassword = await shouldRequirePasswordChange('security.require_password_change_after_admin_reset');
|
||||
await pool.query(
|
||||
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, mustChangePassword ? 1 : 0, getAuditUserId(req), userId]
|
||||
);
|
||||
const encodedPassword = await hashCredentialPassword(password);
|
||||
const mustChangePassword = Object.prototype.hasOwnProperty.call(req.body || {}, 'must_change_password')
|
||||
? req.body.must_change_password === '1' || (Array.isArray(req.body.must_change_password) && req.body.must_change_password.includes('1'))
|
||||
: Boolean(rows[0].must_change_password);
|
||||
await setCredentialPassword(pool, userId, encodedPassword);
|
||||
await pool.query('UPDATE a_users SET must_change_password = ?, modified_by = ? WHERE id = ?', [mustChangePassword ? 1 : 0, getAuditUserId(req), userId]);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderAcceptInvitePage(message, token, email, name, passwordRequirements, isValidInvitation) {
|
||||
const requirements = passwordRequirements || { minimumLength: 10, minimumCategories: 3, requireLowercase: false, requireUppercase: false, requireNumber: false, requireSymbol: false };
|
||||
const requiredCategories = [];
|
||||
if (requirements.requireLowercase) requiredCategories.push('lowercase');
|
||||
if (requirements.requireUppercase) requiredCategories.push('uppercase');
|
||||
if (requirements.requireNumber) requiredCategories.push('number');
|
||||
if (requirements.requireSymbol) requiredCategories.push('symbol');
|
||||
const passwordRequirementsText = requiredCategories.length
|
||||
? 'Use at least ' + requirements.minimumLength + ' characters and include ' + requiredCategories.join(', ') + '.'
|
||||
: requirements.minimumCategories === 4
|
||||
? 'Use at least ' + requirements.minimumLength + ' characters and include uppercase, lowercase, number, and symbol.'
|
||||
: 'Use at least ' + requirements.minimumLength + ' characters and include ' + requirements.minimumCategories + ' of: uppercase, lowercase, number, and symbol.';
|
||||
return renderView('auth/accept-invite', {
|
||||
title: 'Accept invitation',
|
||||
authShell: true,
|
||||
bodyClass: 'login-page-body',
|
||||
message: message || '',
|
||||
isValidInvitation: Boolean(isValidInvitation),
|
||||
token: token || '',
|
||||
email: email || '',
|
||||
name: name || '',
|
||||
passwordMinimumLength: requirements.minimumLength,
|
||||
passwordRequirementsText: passwordRequirementsText
|
||||
});
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderEmailVerificationErrorPage(message) {
|
||||
return renderView('auth/email-verification-error', { title: 'Verification link unavailable', authShell: true, bodyClass: 'login-page-body', message: message || 'This email verification link is invalid or has expired.' });
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderEmailVerifiedPage() {
|
||||
return renderView('auth/email-verified', { title: 'Email verified', authShell: true, bodyClass: 'login-page-body' });
|
||||
};
|
||||
+21
-265
@@ -2,7 +2,6 @@
|
||||
|
||||
const { normalizeReturnToPath, getRequestOrigin } = require('#src/web/lib/auth/session');
|
||||
const { fetchAppSettings } = require('#src/data/app-settings');
|
||||
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
|
||||
const { hasPermission } = require('#src/rbac');
|
||||
|
||||
const DEFAULT_LANDING_ROUTES = [
|
||||
@@ -21,7 +20,6 @@ const DEFAULT_LANDING_ROUTES = [
|
||||
['weather.read', '/data-sources/weather'],
|
||||
['users.read', '/settings/users'],
|
||||
['rbac.read', '/settings/roles'],
|
||||
['invitations.read', '/settings/invitations'],
|
||||
['fonts.read', '/settings/fonts'],
|
||||
['weather-icons.read', '/settings/weather-icons'],
|
||||
['background-tasks.read', '/settings/tasks-background'],
|
||||
@@ -41,81 +39,18 @@ function getDefaultLandingPath(currentUser) {
|
||||
module.exports = function registerAuthRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
const createUserSession = deps.createUserSession;
|
||||
const setSessionCookie = deps.setSessionCookie;
|
||||
const getSessionMaxAgeMs = deps.getSessionMaxAgeMs;
|
||||
const betterAuth = deps.betterAuth;
|
||||
const clearSessionCookie = deps.clearSessionCookie;
|
||||
const parseCookies = deps.parseCookies;
|
||||
const consumeAuthMessageCookie = deps.consumeAuthMessageCookie;
|
||||
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
||||
const hashSessionToken = deps.hashSessionToken;
|
||||
const verifyPassword = deps.verifyPassword;
|
||||
const sessionCookieName = deps.sessionCookieName;
|
||||
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||
const sendAccountEmail = deps.sendAccountEmail;
|
||||
const createOneTimeToken = deps.createOneTimeToken;
|
||||
const getConnection = deps.getConnection;
|
||||
const rbacData = deps.rbacData;
|
||||
|
||||
function getReturnTo(req) {
|
||||
return normalizeReturnToPath(req && (req.query && req.query.returnTo || req.body && req.body.returnTo), getRequestOrigin(req));
|
||||
}
|
||||
|
||||
function buildLoginRedirect(returnTo, username) {
|
||||
const query = [];
|
||||
if (returnTo) query.push('returnTo=' + encodeURIComponent(returnTo));
|
||||
if (username) query.push('username=' + encodeURIComponent(username));
|
||||
return query.length ? '/login?' + query.join('&') : '/login';
|
||||
}
|
||||
|
||||
function getClientAddress(req) {
|
||||
const forwardedAddress = String(req && req.headers && req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||
return forwardedAddress || String(req && req.ip || req && req.socket && req.socket.remoteAddress || 'unknown').trim() || 'unknown';
|
||||
}
|
||||
|
||||
function getRateLimitKey(username, address, scope) {
|
||||
if (scope === 'username') return 'username:' + username;
|
||||
if (scope === 'ip') return 'ip:' + address;
|
||||
return 'both:' + JSON.stringify([username, address]);
|
||||
}
|
||||
|
||||
async function getLoginRateLimitSettings() {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
return {
|
||||
maxAttempts: Number(settings['security.login_max_attempts']) || 5,
|
||||
lockoutMinutes: Number(settings['security.login_lockout_minutes']) || 15,
|
||||
scope: String(settings['security.login_rate_limit_scope'] || 'both')
|
||||
};
|
||||
}
|
||||
|
||||
async function getLoginRateLimitStatus(rateKey) {
|
||||
const [rows] = await pool.query('SELECT locked_until FROM a_login_attempts WHERE rate_key = ? LIMIT 1', [rateKey]);
|
||||
const lockedUntil = rows && rows[0] && rows[0].locked_until ? new Date(rows[0].locked_until).getTime() : 0;
|
||||
return {
|
||||
limited: lockedUntil > Date.now(),
|
||||
remainingMinutes: Math.max(1, Math.ceil((lockedUntil - Date.now()) / 60000))
|
||||
};
|
||||
}
|
||||
|
||||
async function recordFailedLogin(rateKey, settings) {
|
||||
const [rows] = await pool.query('SELECT failed_count, locked_until FROM a_login_attempts WHERE rate_key = ? LIMIT 1', [rateKey]);
|
||||
const existing = rows && rows[0] ? rows[0] : null;
|
||||
const existingLockedUntil = existing && existing.locked_until ? new Date(existing.locked_until).getTime() : 0;
|
||||
const currentCount = existingLockedUntil && existingLockedUntil <= Date.now() ? 0 : Number(existing && existing.failed_count) || 0;
|
||||
const failedCount = currentCount + 1;
|
||||
const lockedUntil = failedCount >= settings.maxAttempts ? new Date(Date.now() + settings.lockoutMinutes * 60 * 1000) : null;
|
||||
if (existing) {
|
||||
await pool.query('UPDATE a_login_attempts SET failed_count = ?, last_failed_at = ?, locked_until = ? WHERE rate_key = ?', [failedCount, new Date(), lockedUntil, rateKey]);
|
||||
return lockedUntil ? { remainingMinutes: Math.max(1, Math.ceil((lockedUntil.getTime() - Date.now()) / 60000)) } : null;
|
||||
}
|
||||
await pool.query('INSERT INTO a_login_attempts (rate_key, failed_count, last_failed_at, locked_until) VALUES (?, ?, ?, ?)', [rateKey, failedCount, new Date(), lockedUntil]);
|
||||
return lockedUntil ? { remainingMinutes: Math.max(1, Math.ceil((lockedUntil.getTime() - Date.now()) / 60000)) } : null;
|
||||
}
|
||||
|
||||
async function clearFailedLogins(rateKey) {
|
||||
await pool.query('DELETE FROM a_login_attempts WHERE rate_key = ?', [rateKey]);
|
||||
}
|
||||
|
||||
async function getPasswordRequirements() {
|
||||
const settings = await fetchAppSettings(pool);
|
||||
return {
|
||||
@@ -128,55 +63,6 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/accept-invite', async function (req, res, next) {
|
||||
try {
|
||||
const token = String(req.query.token || '').trim();
|
||||
const [rows] = await pool.query('SELECT email, name FROM a_user_invitations WHERE token_hash = ? AND used_at IS NULL AND expires_at > NOW() LIMIT 1', [hashSessionToken(token)]);
|
||||
const invitation = rows[0] || null;
|
||||
if (!invitation) return res.status(400).send(pages.renderAcceptInvitePage('This invitation is invalid or has expired.', token, '', '', await getPasswordRequirements(), false));
|
||||
res.send(pages.renderAcceptInvitePage('', token, invitation.email, invitation.name, await getPasswordRequirements(), true));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/accept-invite', async function (req, res, next) {
|
||||
const connection = typeof getConnection === 'function' ? await getConnection() : pool;
|
||||
try {
|
||||
const token = String(req.body.token || '').trim();
|
||||
const username = String(req.body.username || '').trim();
|
||||
const name = String(req.body.name || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const [rows] = await connection.query('SELECT id, email, name, role_ids_json FROM a_user_invitations WHERE token_hash = ? AND used_at IS NULL AND expires_at > NOW() LIMIT 1', [hashSessionToken(token)]);
|
||||
const invitation = rows[0] || null;
|
||||
const requirements = await getPasswordRequirements();
|
||||
const renderError = function (message) { return res.status(400).send(pages.renderAcceptInvitePage(message, token, invitation ? invitation.email : '', name || (invitation && invitation.name) || '', requirements, Boolean(invitation))); };
|
||||
if (!invitation) return renderError('This invitation is invalid or has expired.');
|
||||
if (!username) return renderError('Username is required.');
|
||||
if (!name) return renderError('Name is required.');
|
||||
const [existingUsers] = await connection.query('SELECT id FROM a_users WHERE username = ? LIMIT 1', [username]);
|
||||
if (existingUsers.length) return renderError('That username already exists.');
|
||||
const strengthMessage = deps.validatePasswordStrength(password, requirements);
|
||||
if (strengthMessage) return renderError(strengthMessage);
|
||||
if (password !== confirmPassword) return renderError('Passwords do not match.');
|
||||
const passwordRecord = deps.hashPassword(password);
|
||||
const roleIds = JSON.parse(invitation.role_ids_json || '[]');
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query('INSERT INTO a_users (name, username, email, email_verified_at, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, NOW(), ?, ?, ?, NULL, NULL)', [name, username, invitation.email, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations]);
|
||||
if (rbacData && typeof rbacData.syncUserRoles === 'function') await rbacData.syncUserRoles(connection, result.insertId, roleIds);
|
||||
await connection.query('UPDATE a_user_invitations SET used_at = NOW() WHERE id = ? AND used_at IS NULL', [invitation.id]);
|
||||
await connection.commit();
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'users', eventType: 'user.invitation_accepted', targetType: 'user', targetId: result.insertId, targetLabel: username, details: { invitationId: invitation.id } });
|
||||
res.redirect('/login?message=' + encodeURIComponent('Account created. You can now sign in.'));
|
||||
} catch (error) {
|
||||
try { await connection.rollback(); } catch (_rollbackError) {}
|
||||
next(error);
|
||||
} finally {
|
||||
if (connection !== pool && connection && typeof connection.release === 'function') connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/forgot-password', function (req, res) {
|
||||
res.send(pages.renderForgotPasswordPage(''));
|
||||
});
|
||||
@@ -184,24 +70,15 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
app.post('/forgot-password', async function (req, res, next) {
|
||||
try {
|
||||
const identity = String(req.body.identity || '').trim();
|
||||
const genericMessage = 'If that account has a verified email address, a reset link has been sent.';
|
||||
const [rows] = await pool.query('SELECT id, name, username, email FROM a_users WHERE username = ? OR (email = ? AND email_verified_at IS NOT NULL) LIMIT 1', [identity, identity.toLowerCase()]);
|
||||
const genericMessage = 'If that account has a verified email address, a reset code has been sent.';
|
||||
const [rows] = await pool.query('SELECT id, email, email_verified FROM a_users WHERE username = ? OR (email = ? AND email_verified = 1) LIMIT 1', [identity, identity.toLowerCase()]);
|
||||
const user = rows[0] || null;
|
||||
const settings = await fetchAppSettings(pool);
|
||||
if (user && user.email && user.email_verified_at && typeof sendAccountEmail === 'function' && typeof createOneTimeToken === 'function') {
|
||||
const token = createOneTimeToken();
|
||||
await pool.query('DELETE FROM a_account_tokens WHERE user_id = ? AND token_type = ?', [user.id, 'password-reset']);
|
||||
const expiryMinutes = Number(settings['email.reset_expiry_minutes']);
|
||||
await pool.query('INSERT INTO a_account_tokens (user_id, token_type, token_hash, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL ' + expiryMinutes + ' MINUTE))', [user.id, 'password-reset', hashSessionToken(token)]);
|
||||
const resetUrl = getRequestOrigin(req) + '/reset-password?token=' + encodeURIComponent(token);
|
||||
try {
|
||||
await sendAccountEmail(settings, Object.assign({ to: user.email }, renderAccountEmailTemplate(settings['email.reset_subject'], settings['email.reset_body'], { url: resetUrl, username: user.username, display_name: user.name, email: user.email, expiry_time: formatAccountEmailExpiry(expiryMinutes, 'minutes'), action_alignment: settings['email.reset_button_alignment'], action_label: settings['email.reset_button_text'] })));
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'password.reset_requested', targetType: 'user', targetId: user.id, targetLabel: user.username });
|
||||
} catch (_mailError) {
|
||||
await pool.query('DELETE FROM a_account_tokens WHERE token_hash = ?', [hashSessionToken(token)]);
|
||||
}
|
||||
if (user && user.email && user.email_verified && settings['email.smtp_enabled']) {
|
||||
await betterAuth.api.requestPasswordResetEmailOTP({ body: { email: user.email } });
|
||||
}
|
||||
res.send(pages.renderForgotPasswordPage(genericMessage));
|
||||
const resetEmail = identity.indexOf('@') !== -1 ? identity.toLowerCase() : '';
|
||||
res.send(pages.renderResetPasswordPage(genericMessage, resetEmail, await getPasswordRequirements()));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -209,9 +86,7 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
|
||||
app.get('/reset-password', async function (req, res, next) {
|
||||
try {
|
||||
const token = String(req.query.token || '').trim();
|
||||
if (!token) return res.redirect('/forgot-password');
|
||||
res.send(pages.renderResetPasswordPage('', token, await getPasswordRequirements()));
|
||||
res.send(pages.renderResetPasswordPage('', String(req.query.email || ''), await getPasswordRequirements()));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -219,34 +94,19 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
|
||||
app.post('/reset-password', async function (req, res, next) {
|
||||
try {
|
||||
const token = String(req.body.token || '');
|
||||
const email = String(req.body.email || '').trim().toLowerCase();
|
||||
const otp = String(req.body.otp || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const [rows] = await pool.query('SELECT t.id AS token_id, t.user_id, u.username, u.email FROM a_account_tokens t JOIN a_users u ON u.id = t.user_id WHERE t.token_hash = ? AND t.token_type = ? AND t.used_at IS NULL AND t.expires_at > NOW() LIMIT 1', [hashSessionToken(token), 'password-reset']);
|
||||
const record = rows[0] || null;
|
||||
if (!record) return res.status(400).send(pages.renderResetPasswordPage('This reset link is invalid or has expired.', token, await getPasswordRequirements()));
|
||||
const strengthMessage = deps.validatePasswordStrength(password, await getPasswordRequirements());
|
||||
if (strengthMessage || password !== confirmPassword) return res.status(400).send(pages.renderResetPasswordPage(strengthMessage || 'Passwords do not match.', token, await getPasswordRequirements()));
|
||||
const passwordRecord = deps.hashPassword(password);
|
||||
await pool.query('UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, must_change_password = 0, modified_at = CURRENT_TIMESTAMP WHERE id = ?', [passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, record.user_id]);
|
||||
await pool.query('UPDATE a_account_tokens SET used_at = NOW() WHERE id = ?', [record.token_id]);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [record.user_id]);
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'password.reset', targetType: 'user', targetId: record.user_id, targetLabel: record.username, details: { source: 'email' } });
|
||||
res.redirect('/login?message=' + encodeURIComponent('Password updated. You can now sign in.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/verify-email', async function (req, res, next) {
|
||||
try {
|
||||
const tokenHash = hashSessionToken(String(req.query.token || ''));
|
||||
const [rows] = await pool.query('SELECT id FROM a_users WHERE pending_email_token_hash = ? AND pending_email_expires_at > NOW() LIMIT 1', [tokenHash]);
|
||||
const user = rows[0] || null;
|
||||
if (!user) return res.status(400).send(pages.renderEmailVerificationErrorPage('This email verification link is invalid or has expired.'));
|
||||
await pool.query('UPDATE a_users SET email = pending_email, email_verified_at = NOW(), pending_email = NULL, pending_email_token_hash = NULL, pending_email_expires_at = NULL WHERE id = ?', [user.id]);
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'security', eventType: 'email.verification_completed', targetType: 'user', targetId: user.id });
|
||||
res.send(pages.renderEmailVerifiedPage());
|
||||
const requirements = await getPasswordRequirements();
|
||||
const strengthMessage = deps.validatePasswordStrength(password, requirements);
|
||||
if (strengthMessage || password !== confirmPassword) return res.status(400).send(pages.renderResetPasswordPage(strengthMessage || 'Passwords do not match.', email, requirements));
|
||||
try {
|
||||
await betterAuth.api.resetPasswordEmailOTP({ body: { email: email, otp: otp, password: password } });
|
||||
} catch (error) {
|
||||
return res.status(400).send(pages.renderResetPasswordPage('The email code is invalid or expired. Request a new code and try again.', email, requirements));
|
||||
}
|
||||
res.send(pages.renderForgotPasswordPage('Password updated. You can now sign in.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -279,116 +139,12 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/login', async function (req, res, next) {
|
||||
try {
|
||||
const username = String(req.body.username || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
if (!username || !password) {
|
||||
return res.status(400).send('Username and password are required.');
|
||||
}
|
||||
|
||||
const loginRateLimitSettings = await getLoginRateLimitSettings();
|
||||
const rateKey = getRateLimitKey(username.toLowerCase(), getClientAddress(req), loginRateLimitSettings.scope);
|
||||
const rateLimitStatus = await getLoginRateLimitStatus(rateKey);
|
||||
if (rateLimitStatus.limited) {
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'authentication',
|
||||
eventType: 'login.locked_out',
|
||||
targetType: 'user',
|
||||
targetLabel: username,
|
||||
details: { reason: 'rate_limit' }
|
||||
});
|
||||
}
|
||||
const message = 'Too many failed login attempts. Try again in ' + rateLimitStatus.remainingMinutes + ' minute' + (rateLimitStatus.remainingMinutes === 1 ? '' : 's') + '.';
|
||||
const returnTo = getReturnTo(req);
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, message);
|
||||
return res.redirect(buildLoginRedirect(returnTo, username));
|
||||
}
|
||||
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
|
||||
}
|
||||
|
||||
const [usernameRows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations, account_locked FROM a_users WHERE username = ? LIMIT 1', [username]);
|
||||
let user = usernameRows[0] || null;
|
||||
if (!user) {
|
||||
const [emailRows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations, account_locked FROM a_users WHERE email = ? AND email_verified_at IS NOT NULL LIMIT 1', [username.toLowerCase()]);
|
||||
user = emailRows[0] || null;
|
||||
}
|
||||
if (user && user.account_locked) {
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'security',
|
||||
eventType: 'login.blocked_account_locked',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
targetLabel: user.username
|
||||
});
|
||||
}
|
||||
const returnTo = getReturnTo(req);
|
||||
const message = 'This account is locked. Contact an administrator.';
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, message);
|
||||
return res.redirect(buildLoginRedirect(returnTo, username));
|
||||
}
|
||||
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
|
||||
}
|
||||
if (!user || !verifyPassword(password, user)) {
|
||||
const lockout = await recordFailedLogin(rateKey, loginRateLimitSettings);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'authentication',
|
||||
eventType: lockout ? 'login.lockout' : 'login.failed',
|
||||
targetType: user ? 'user' : 'username',
|
||||
targetId: user ? user.id : null,
|
||||
targetLabel: user ? user.username : username,
|
||||
details: { reason: user ? 'invalid_password' : 'unknown_username' }
|
||||
});
|
||||
}
|
||||
const message = lockout
|
||||
? 'Too many failed login attempts. Try again in ' + lockout.remainingMinutes + ' minute' + (lockout.remainingMinutes === 1 ? '' : 's') + '.'
|
||||
: 'Invalid username or password.';
|
||||
const returnTo = getReturnTo(req);
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, message);
|
||||
return res.redirect(buildLoginRedirect(returnTo, username));
|
||||
}
|
||||
|
||||
return res.status(401).send(pages.renderLoginPage(message, returnTo, username));
|
||||
}
|
||||
|
||||
await clearFailedLogins(rateKey);
|
||||
const returnTo = getReturnTo(req);
|
||||
const sessionMaxAgeMs = typeof getSessionMaxAgeMs === 'function' ? await getSessionMaxAgeMs() : undefined;
|
||||
const token = await createUserSession(pool, user.id, sessionMaxAgeMs, {
|
||||
ipAddress: getClientAddress(req),
|
||||
userAgent: req.headers && req.headers['user-agent']
|
||||
});
|
||||
const rememberMe = req.body.remember_me === '1' || req.body.remember_me === 'true' || req.body.remember_me === true;
|
||||
setSessionCookie(res, token, rememberMe ? sessionMaxAgeMs : null);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'authentication',
|
||||
eventType: 'login.success',
|
||||
actorUserId: user.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
targetLabel: user.username,
|
||||
details: { rememberMe: rememberMe }
|
||||
});
|
||||
}
|
||||
res.redirect(returnTo || '/');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/logout', async function (req, res, next) {
|
||||
try {
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
const token = req.betterAuthSession && req.betterAuthSession.session && req.betterAuthSession.session.token || cookies[sessionCookieName];
|
||||
if (token) {
|
||||
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [hashSessionToken(token)]);
|
||||
await pool.query('DELETE FROM a_sessions WHERE session_hash = ?', [token]);
|
||||
}
|
||||
const actor = req.authenticatedUser || req.currentUser;
|
||||
if (typeof recordRequestAuditEvent === 'function' && actor) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Sign-in page renderer for the authentication flow.
|
||||
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { resolvePasskeyRpID } = require('#src/web/lib/auth/better-auth');
|
||||
|
||||
module.exports = function renderLoginPage(message, returnTo, username, showForgotPassword) {
|
||||
return renderView('auth/login', {
|
||||
@@ -11,6 +12,8 @@ module.exports = function renderLoginPage(message, returnTo, username, showForgo
|
||||
returnTo: returnTo || '',
|
||||
username: username || '',
|
||||
showForgotPassword: Boolean(showForgotPassword),
|
||||
messageVariant: 'warning'
|
||||
passkeyRpID: resolvePasskeyRpID(process.env.BETTER_AUTH_URL || process.env.WEB_PUBLIC_URL),
|
||||
messageVariant: 'warning',
|
||||
scripts: ['js/auth/passkey-client.js', 'js/auth/login.js']
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderResetPasswordPage(message, token, passwordRequirements) {
|
||||
module.exports = function renderResetPasswordPage(message, email, passwordRequirements) {
|
||||
const requirements = passwordRequirements || { minimumLength: 10, minimumCategories: 3, requireLowercase: false, requireUppercase: false, requireNumber: false, requireSymbol: false };
|
||||
const requiredCategories = [];
|
||||
if (requirements.requireLowercase) requiredCategories.push('lowercase');
|
||||
@@ -12,5 +12,5 @@ module.exports = function renderResetPasswordPage(message, token, passwordRequir
|
||||
: requirements.minimumCategories === 4
|
||||
? 'Use at least ' + requirements.minimumLength + ' characters and include uppercase, lowercase, number, and symbol.'
|
||||
: 'Use at least ' + requirements.minimumLength + ' characters and include ' + requirements.minimumCategories + ' of: uppercase, lowercase, number, and symbol.';
|
||||
return renderView('auth/reset-password', { title: 'Choose a new password', authShell: true, bodyClass: 'login-page-body', message: message || '', token: token || '', passwordMinimumLength: requirements.minimumLength, passwordRequirementsText: passwordRequirementsText });
|
||||
return renderView('auth/reset-password', { title: 'Choose a new password', authShell: true, bodyClass: 'login-page-body', message: message || '', email: email || '', passwordMinimumLength: requirements.minimumLength, passwordRequirementsText: passwordRequirementsText });
|
||||
};
|
||||
@@ -86,25 +86,16 @@ function registerAuthAndAccountRoutes(app, deps) {
|
||||
// Auth, profile, and user management routes share the same session helpers.
|
||||
registerAuthRoutes(app, {
|
||||
pool: deps.pool,
|
||||
betterAuth: deps.betterAuth,
|
||||
fromNodeHeaders: deps.fromNodeHeaders,
|
||||
pages: deps.pages,
|
||||
createUserSession: deps.createUserSession,
|
||||
setSessionCookie: deps.setSessionCookie,
|
||||
getSessionMaxAgeMs: deps.getSessionMaxAgeMs,
|
||||
getConfiguredMaxActiveSessions: deps.getConfiguredMaxActiveSessions,
|
||||
clearSessionCookie: deps.clearSessionCookie,
|
||||
setAuthMessageCookie: deps.setAuthMessageCookie,
|
||||
consumeAuthMessageCookie: deps.consumeAuthMessageCookie,
|
||||
parseCookies: deps.parseCookies,
|
||||
hashSessionToken: deps.hashSessionToken,
|
||||
verifyPassword: deps.verifyPassword,
|
||||
hashPassword: deps.hashPassword,
|
||||
validatePasswordStrength: deps.validatePasswordStrength,
|
||||
sessionCookieName: deps.sessionCookieName,
|
||||
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||
,sendAccountEmail: deps.sendAccountEmail
|
||||
,createOneTimeToken: deps.createOneTimeToken
|
||||
,getConnection: deps.pool.getConnection ? deps.pool.getConnection.bind(deps.pool) : null
|
||||
,rbacData: deps.rbacData
|
||||
});
|
||||
|
||||
registerPagesRoutes(app, {
|
||||
@@ -120,22 +111,18 @@ function registerAuthAndAccountRoutes(app, deps) {
|
||||
|
||||
registerAccountRoutes(app, {
|
||||
pool: deps.pool,
|
||||
betterAuth: deps.betterAuth,
|
||||
fromNodeHeaders: deps.fromNodeHeaders,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
formatDashboardDate: deps.formatDashboardDate,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
verifyPassword: deps.verifyPassword,
|
||||
hashPassword: deps.hashPassword,
|
||||
validatePasswordStrength: deps.validatePasswordStrength,
|
||||
createUserSession: deps.createUserSession,
|
||||
setSessionCookie: deps.setSessionCookie,
|
||||
getSessionMaxAgeMs: deps.getSessionMaxAgeMs,
|
||||
parseCookies: deps.parseCookies,
|
||||
hashSessionToken: deps.hashSessionToken,
|
||||
sessionCookieName: deps.sessionCookieName,
|
||||
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||
,sendAccountEmail: deps.sendAccountEmail
|
||||
,createOneTimeToken: deps.createOneTimeToken
|
||||
});
|
||||
|
||||
registerUsersRoutes(app, {
|
||||
@@ -150,10 +137,6 @@ function registerAuthAndAccountRoutes(app, deps) {
|
||||
rbacData: deps.rbacData,
|
||||
requirePermission: deps.requirePermission,
|
||||
recordRequestAuditEvent: deps.recordRequestAuditEvent
|
||||
,sendAccountEmail: deps.sendAccountEmail
|
||||
,createOneTimeToken: deps.createOneTimeToken
|
||||
,hashSessionToken: deps.hashSessionToken
|
||||
,getRequestOrigin: deps.getRequestOrigin
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Pending invitations page renderer.
|
||||
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderInvitationsPage(data, currentUser) {
|
||||
return renderView('settings/invitations/list', {
|
||||
title: 'Pending invitations',
|
||||
active: 'users-invitations',
|
||||
userManagementMenuOpen: true,
|
||||
currentUser: currentUser || null,
|
||||
message: data.message || '',
|
||||
invitations: data.invitations || [],
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
@@ -10,7 +10,8 @@ function buildDuplicateRole(role, duplicateName) {
|
||||
return {
|
||||
id: null,
|
||||
name: String(duplicateName || buildDuplicateRoleName(source.name)).trim(),
|
||||
description: String(source.description || '').trim()
|
||||
description: String(source.description || '').trim(),
|
||||
require_two_factor: Boolean(source.require_two_factor)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const { ANNOUNCEMENT_ICON_CATALOG } = require('#src/data/announcement-icons');
|
||||
const { AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS, SCREEN_CONTROL_COMMAND_KEYS, SCREEN_CONTROL_COMMAND_LABELS } = require('#src/data/audit-log');
|
||||
const { fetchAppSettings, saveAppSettings } = require('#src/data/app-settings');
|
||||
const { normalizeOtpEmailBody } = require('#src/data/account-email-templates');
|
||||
const { appVersion, detectSchemaVersion } = require('#src/db/migrations');
|
||||
|
||||
const UPLOAD_MIME_TYPE_OPTIONS = [
|
||||
@@ -42,24 +43,17 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
'data-sources.api_default_interval_unit'
|
||||
],
|
||||
security: [
|
||||
'security.session_lifetime_days',
|
||||
'security.allow_user_session_revocation',
|
||||
'security.max_active_sessions',
|
||||
'security.login_max_attempts',
|
||||
'security.login_lockout_minutes',
|
||||
'security.login_rate_limit_scope',
|
||||
'security.password_min_length',
|
||||
'security.password_min_categories',
|
||||
'security.password_require_lowercase',
|
||||
'security.password_require_uppercase',
|
||||
'security.password_require_number',
|
||||
'security.password_require_symbol',
|
||||
'security.require_password_change_for_new_users',
|
||||
'security.require_password_change_after_admin_reset',
|
||||
'security.allow_admin_email_verification_bypass'
|
||||
],
|
||||
email: ['email.smtp_enabled', 'email.smtp_host', 'email.smtp_port', 'email.smtp_security', 'email.smtp_username', 'email.from_address', 'email.from_name'],
|
||||
'email-templates': ['email.verification_subject', 'email.verification_body', 'email.verification_button_alignment', 'email.verification_button_text', 'email.reset_subject', 'email.reset_body', 'email.reset_button_alignment', 'email.reset_button_text', 'email.invitation_subject', 'email.invitation_body', 'email.invitation_button_alignment', 'email.invitation_button_text'],
|
||||
email: ['email.smtp_enabled', 'email.smtp_host', 'email.smtp_port', 'email.smtp_security', 'email.smtp_username', 'email.from_address', 'email.from_name', 'email.otp_expiry_minutes'],
|
||||
'email-templates': ['email.verification_subject', 'email.verification_body', 'email.reset_subject', 'email.reset_body'],
|
||||
audit: ['audit.enabled', 'audit.categories', 'audit.screen_control_commands', 'audit.include_request_metadata', 'audit.retention_days'],
|
||||
media: ['uploads.image_max_bytes', 'uploads.video_max_bytes', 'uploads.wysiwyg_image_max_bytes', 'uploads.allowed_mime_types'],
|
||||
weather: ['weather.open_meteo_api_key', 'weather.pirate_weather_api_key'],
|
||||
@@ -152,20 +146,13 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
videoMimeTypes: UPLOAD_MIME_TYPE_OPTIONS.filter(function (option) { return option.category === 'Video'; }).map(function (option) {
|
||||
return Object.assign({}, option, { isSelected: settings['uploads.allowed_mime_types'].indexOf(option.value) !== -1 });
|
||||
}),
|
||||
sessionLifetimeDays: Number(settings['security.session_lifetime_days']) || 14,
|
||||
allowUserSessionRevocation: Boolean(settings['security.allow_user_session_revocation']),
|
||||
maxActiveSessions: Number(settings['security.max_active_sessions']) || 0,
|
||||
loginMaxAttempts: Number(settings['security.login_max_attempts']) || 5,
|
||||
loginLockoutMinutes: Number(settings['security.login_lockout_minutes']) || 15,
|
||||
loginRateLimitScope: String(settings['security.login_rate_limit_scope'] || 'both'),
|
||||
passwordMinLength: Number(settings['security.password_min_length']) || 10,
|
||||
passwordMinCategories: Number(settings['security.password_min_categories']) || 3,
|
||||
passwordRequireLowercase: Boolean(settings['security.password_require_lowercase']),
|
||||
passwordRequireUppercase: Boolean(settings['security.password_require_uppercase']),
|
||||
passwordRequireNumber: Boolean(settings['security.password_require_number']),
|
||||
passwordRequireSymbol: Boolean(settings['security.password_require_symbol']),
|
||||
requirePasswordChangeForNewUsers: Boolean(settings['security.require_password_change_for_new_users']),
|
||||
requirePasswordChangeAfterAdminReset: Boolean(settings['security.require_password_change_after_admin_reset']),
|
||||
allowAdminEmailVerificationBypass: Boolean(settings['security.allow_admin_email_verification_bypass']),
|
||||
smtpEnabled: Boolean(settings['email.smtp_enabled']),
|
||||
smtpHost: String(settings['email.smtp_host'] || ''),
|
||||
@@ -175,21 +162,11 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
smtpConfigured: Boolean(settings['email.smtp_password']),
|
||||
emailFromAddress: String(settings['email.from_address'] || ''),
|
||||
emailFromName: String(settings['email.from_name'] || ''),
|
||||
verificationExpiryMinutes: Number(settings['email.verification_expiry_minutes']) || 30,
|
||||
resetExpiryMinutes: Number(settings['email.reset_expiry_minutes']) || 30,
|
||||
invitationExpiryHours: Number(settings['email.invitation_expiry_hours']) || 24,
|
||||
otpExpiryMinutes: Number(settings['email.otp_expiry_minutes']) || 30,
|
||||
verificationEmailSubject: String(settings['email.verification_subject'] || ''),
|
||||
verificationEmailBody: String(settings['email.verification_body'] || ''),
|
||||
verificationButtonAlignment: String(settings['email.verification_button_alignment'] || 'center'),
|
||||
verificationButtonText: String(settings['email.verification_button_text'] || 'Verify email address'),
|
||||
verificationEmailBody: normalizeOtpEmailBody(settings['email.verification_body']),
|
||||
resetEmailSubject: String(settings['email.reset_subject'] || ''),
|
||||
resetEmailBody: String(settings['email.reset_body'] || ''),
|
||||
resetButtonAlignment: String(settings['email.reset_button_alignment'] || 'center'),
|
||||
resetButtonText: String(settings['email.reset_button_text'] || 'Reset password'),
|
||||
invitationEmailSubject: String(settings['email.invitation_subject'] || ''),
|
||||
invitationEmailBody: String(settings['email.invitation_body'] || ''),
|
||||
invitationButtonAlignment: String(settings['email.invitation_button_alignment'] || 'center'),
|
||||
invitationButtonText: String(settings['email.invitation_button_text'] || 'Accept invitation'),
|
||||
resetEmailBody: normalizeOtpEmailBody(settings['email.reset_body']),
|
||||
defaultSlideDurationSeconds: Number(settings['player.default_slide_duration_seconds']) || 10,
|
||||
defaultFadeBetweenSlides: Boolean(settings['player.default_fade_between_slides']),
|
||||
defaultSkipUnavailableRtmp: Boolean(settings['player.skip_unavailable_rtmp']),
|
||||
@@ -279,6 +256,25 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/system/login-lockouts/reset', deps.requirePermission('login-lockouts.allow'), async function (req, res, next) {
|
||||
try {
|
||||
await pool.query('DELETE FROM a_login_attempts WHERE rate_key LIKE ? OR rate_key LIKE ?', ['%|/sign-in/%', '%|/two-factor/verify-%']);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(pool, req, {
|
||||
category: 'system-settings',
|
||||
eventType: 'system-settings.login_lockouts_reset',
|
||||
actorUserId: req.currentUser && req.currentUser.id,
|
||||
targetType: 'settings',
|
||||
targetId: 'security',
|
||||
targetLabel: 'All login lockouts'
|
||||
});
|
||||
}
|
||||
res.redirect('/settings/system?message=' + encodeURIComponent('Sign-in and two-factor request rate limits reset for all clients.') + '#security-sessions');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/settings/system', deps.requirePermission('system-settings.update'), async function (req, res, next) {
|
||||
try {
|
||||
const section = String(req.body && req.body.settings_section || '').trim().toLowerCase();
|
||||
@@ -314,16 +310,11 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
|
||||
if (section === 'email-templates') {
|
||||
const verificationSubject = String(req.body && req.body.verification_subject || '').trim();
|
||||
const verificationBody = String(req.body && req.body.verification_body || '').trim();
|
||||
const verificationBody = normalizeOtpEmailBody(req.body && req.body.verification_body).trim();
|
||||
const resetSubject = String(req.body && req.body.reset_subject || '').trim();
|
||||
const resetBody = String(req.body && req.body.reset_body || '').trim();
|
||||
const verificationButtonText = String(req.body && req.body.verification_button_text || '').trim();
|
||||
const resetButtonText = String(req.body && req.body.reset_button_text || '').trim();
|
||||
const invitationSubject = String(req.body && req.body.invitation_subject || '').trim();
|
||||
const invitationBody = String(req.body && req.body.invitation_body || '').trim();
|
||||
const invitationButtonText = String(req.body && req.body.invitation_button_text || '').trim();
|
||||
if (!verificationSubject || !verificationBody.includes('[[url]]') || !resetSubject || !resetBody.includes('[[url]]') || !invitationSubject || !invitationBody.includes('[[url]]') || !verificationButtonText || !resetButtonText || !invitationButtonText) {
|
||||
const error = new Error('Email templates require a subject, message, and the correct link placeholder.');
|
||||
const resetBody = normalizeOtpEmailBody(req.body && req.body.reset_body).trim();
|
||||
if (!verificationSubject || !verificationBody.includes('[[otp]]') || !resetSubject || !resetBody.includes('[[otp]]')) {
|
||||
const error = new Error('Email templates require a subject, message, and the correct code or link placeholder.');
|
||||
error.statusCode = 400;
|
||||
error.expose = true;
|
||||
throw error;
|
||||
@@ -331,16 +322,8 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
const savedSettings = await saveAppSettings(pool, {
|
||||
'email.verification_subject': verificationSubject,
|
||||
'email.verification_body': verificationBody,
|
||||
'email.verification_button_alignment': ['left', 'center', 'right'].includes(String(req.body && req.body.verification_button_alignment || '').trim()) ? String(req.body.verification_button_alignment).trim() : 'center',
|
||||
'email.verification_button_text': verificationButtonText,
|
||||
'email.reset_subject': resetSubject,
|
||||
'email.reset_body': resetBody,
|
||||
'email.reset_button_alignment': ['left', 'center', 'right'].includes(String(req.body && req.body.reset_button_alignment || '').trim()) ? String(req.body.reset_button_alignment).trim() : 'center',
|
||||
'email.reset_button_text': resetButtonText
|
||||
,'email.invitation_subject': invitationSubject
|
||||
,'email.invitation_body': invitationBody
|
||||
,'email.invitation_button_alignment': ['left', 'center', 'right'].includes(String(req.body && req.body.invitation_button_alignment || '').trim()) ? String(req.body.invitation_button_alignment).trim() : 'center'
|
||||
,'email.invitation_button_text': invitationButtonText
|
||||
}, req.currentUser && req.currentUser.id);
|
||||
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
|
||||
return res.redirect('/settings/system?message=' + encodeURIComponent('Account email templates saved.') + '#email-templates');
|
||||
@@ -380,43 +363,29 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
}
|
||||
|
||||
if (section === 'security') {
|
||||
const sessionLifetimeDays = Number(req.body && req.body.session_lifetime_days);
|
||||
const allowUserSessionRevocation = req.body && (req.body.allow_user_session_revocation === '1' || req.body.allow_user_session_revocation === 'true');
|
||||
const maxActiveSessions = Number(req.body && req.body.max_active_sessions);
|
||||
const loginMaxAttempts = Number(req.body && req.body.login_max_attempts);
|
||||
const loginLockoutMinutes = Number(req.body && req.body.login_lockout_minutes);
|
||||
const loginRateLimitScope = String(req.body && req.body.login_rate_limit_scope || 'both').trim().toLowerCase();
|
||||
const passwordMinLength = Number(req.body && req.body.password_min_length);
|
||||
const passwordMinCategories = Number(req.body && req.body.password_min_categories);
|
||||
const passwordRequireLowercase = req.body && (req.body.password_require_lowercase === '1' || req.body.password_require_lowercase === 'true');
|
||||
const passwordRequireUppercase = req.body && (req.body.password_require_uppercase === '1' || req.body.password_require_uppercase === 'true');
|
||||
const passwordRequireNumber = req.body && (req.body.password_require_number === '1' || req.body.password_require_number === 'true');
|
||||
const passwordRequireSymbol = req.body && (req.body.password_require_symbol === '1' || req.body.password_require_symbol === 'true');
|
||||
const requirePasswordChangeForNewUsers = req.body && (req.body.require_password_change_for_new_users === '1' || req.body.require_password_change_for_new_users === 'true');
|
||||
const requirePasswordChangeAfterAdminReset = req.body && (req.body.require_password_change_after_admin_reset === '1' || req.body.require_password_change_after_admin_reset === 'true');
|
||||
const requiredCategoryCount = [passwordRequireLowercase, passwordRequireUppercase, passwordRequireNumber, passwordRequireSymbol].filter(Boolean).length;
|
||||
if (!Number.isInteger(sessionLifetimeDays) || sessionLifetimeDays < 1 || sessionLifetimeDays > 365 || !Number.isInteger(maxActiveSessions) || maxActiveSessions < 0 || maxActiveSessions > 100 || !Number.isInteger(loginMaxAttempts) || loginMaxAttempts < 1 || loginMaxAttempts > 100 || !Number.isInteger(loginLockoutMinutes) || loginLockoutMinutes < 1 || loginLockoutMinutes > 1440 || !['both', 'username', 'ip'].includes(loginRateLimitScope) || !Number.isInteger(passwordMinLength) || passwordMinLength < 8 || passwordMinLength > 128 || !Number.isInteger(passwordMinCategories) || passwordMinCategories < 1 || passwordMinCategories > 4 || requiredCategoryCount > passwordMinCategories) {
|
||||
const error = new Error('Session and password settings must use valid values; required password categories cannot exceed the minimum category count.');
|
||||
if (!Number.isInteger(passwordMinLength) || passwordMinLength < 8 || passwordMinLength > 128 || !Number.isInteger(passwordMinCategories) || passwordMinCategories < 1 || passwordMinCategories > 4 || requiredCategoryCount > passwordMinCategories) {
|
||||
const error = new Error('Password settings must use valid values; required password categories cannot exceed the minimum category count.');
|
||||
error.statusCode = 400;
|
||||
error.expose = true;
|
||||
throw error;
|
||||
}
|
||||
const savedSettings = await saveAppSettings(pool, {
|
||||
'security.session_lifetime_days': sessionLifetimeDays,
|
||||
'security.allow_user_session_revocation': allowUserSessionRevocation,
|
||||
'security.max_active_sessions': maxActiveSessions,
|
||||
'security.login_max_attempts': loginMaxAttempts,
|
||||
'security.login_lockout_minutes': loginLockoutMinutes,
|
||||
'security.login_rate_limit_scope': loginRateLimitScope,
|
||||
'security.password_min_length': passwordMinLength,
|
||||
'security.password_min_categories': passwordMinCategories,
|
||||
'security.password_require_lowercase': passwordRequireLowercase,
|
||||
'security.password_require_uppercase': passwordRequireUppercase,
|
||||
'security.password_require_number': passwordRequireNumber,
|
||||
'security.password_require_symbol': passwordRequireSymbol,
|
||||
'security.require_password_change_for_new_users': requirePasswordChangeForNewUsers,
|
||||
'security.require_password_change_after_admin_reset': requirePasswordChangeAfterAdminReset
|
||||
,'security.allow_admin_email_verification_bypass': req.body && (req.body.allow_admin_email_verification_bypass === '1' || req.body.allow_admin_email_verification_bypass === 'true')
|
||||
'security.allow_admin_email_verification_bypass': req.body && (req.body.allow_admin_email_verification_bypass === '1' || req.body.allow_admin_email_verification_bypass === 'true')
|
||||
}, req.currentUser && req.currentUser.id);
|
||||
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
|
||||
return res.redirect('/settings/system?message=' + encodeURIComponent('Security settings saved.') + '#security-sessions');
|
||||
@@ -431,10 +400,8 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
const smtpPassword = String(req.body && req.body.smtp_password || '');
|
||||
const fromAddress = String(req.body && req.body.from_address || '').trim();
|
||||
const fromName = String(req.body && req.body.from_name || '').trim();
|
||||
const verificationExpiryMinutes = Number(req.body && req.body.verification_expiry_minutes);
|
||||
const resetExpiryMinutes = Number(req.body && req.body.reset_expiry_minutes);
|
||||
const invitationExpiryHours = Number(req.body && req.body.invitation_expiry_hours);
|
||||
if (!Number.isInteger(verificationExpiryMinutes) || verificationExpiryMinutes < 1 || verificationExpiryMinutes > 10080 || !Number.isInteger(resetExpiryMinutes) || resetExpiryMinutes < 1 || resetExpiryMinutes > 10080 || !Number.isInteger(invitationExpiryHours) || invitationExpiryHours < 1 || invitationExpiryHours > 720 || (smtpEnabled && (!smtpHost || !Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535 || !fromAddress))) {
|
||||
const otpExpiryMinutes = Number(req.body && req.body.otp_expiry_minutes);
|
||||
if (!Number.isInteger(otpExpiryMinutes) || otpExpiryMinutes < 1 || otpExpiryMinutes > 10080 || (smtpEnabled && (!smtpHost || !Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535 || !fromAddress))) {
|
||||
const error = new Error('Enabled email delivery requires an SMTP host, valid port, and from address.');
|
||||
error.statusCode = 400;
|
||||
error.expose = true;
|
||||
@@ -449,9 +416,7 @@ module.exports = function registerSettingsPageRoutes(app, deps) {
|
||||
'email.smtp_password': smtpPassword || previousSettings['email.smtp_password'],
|
||||
'email.from_address': fromAddress,
|
||||
'email.from_name': fromName,
|
||||
'email.verification_expiry_minutes': verificationExpiryMinutes,
|
||||
'email.reset_expiry_minutes': resetExpiryMinutes,
|
||||
'email.invitation_expiry_hours': invitationExpiryHours
|
||||
'email.otp_expiry_minutes': otpExpiryMinutes
|
||||
}, req.currentUser && req.currentUser.id);
|
||||
await recordSettingsAuditEvent(req, previousSettings, savedSettings, section);
|
||||
return res.redirect('/settings/system?message=' + encodeURIComponent('Email settings saved.') + '#email-delivery');
|
||||
|
||||
@@ -36,12 +36,19 @@ function buildUsersAddViewModel(message, currentUser, roles, formValues, message
|
||||
messageVariant: messageVariant || 'success',
|
||||
currentUser: currentUser || null,
|
||||
roles: mapRolesForForm(roles, []),
|
||||
authMethods: [],
|
||||
formValues: formValues || {},
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/settings/users" data-async-save-new-redirect="response-url" data-async-save-new-url="/settings/users/new"'
|
||||
};
|
||||
}
|
||||
|
||||
function buildUsersEditViewModel(user, message, currentUser, roles, sessions) {
|
||||
const authMethods = Array.isArray(user && user.authMethods) ? user.authMethods : [];
|
||||
const passkeys = Array.isArray(user && user.passkeys) ? user.passkeys : [];
|
||||
const mappedUser = Object.assign({}, user, {
|
||||
passkeys: passkeys,
|
||||
canUnlinkPasskeys: Boolean(currentUser && Number(currentUser.id) !== Number(user && user.id) && passkeys.length > 0)
|
||||
});
|
||||
return {
|
||||
title: 'Edit user',
|
||||
active: 'users',
|
||||
@@ -60,7 +67,9 @@ function buildUsersEditViewModel(user, message, currentUser, roles, sessions) {
|
||||
showSaveSecondaryActions: true,
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
user: user,
|
||||
user: mappedUser,
|
||||
authMethods: authMethods,
|
||||
passkeys: passkeys,
|
||||
sessions: Array.isArray(sessions) ? sessions : [],
|
||||
inUse: Boolean(user && user.inUse),
|
||||
roles: mapRolesForForm(roles, user && user.roleIds),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
const { renderView } = require('#src/web/view');
|
||||
|
||||
module.exports = function renderUsersInvitePage(message, currentUser, roles, formValues, messageVariant) {
|
||||
return renderView('settings/users/invite', {
|
||||
title: 'Invite user',
|
||||
active: 'users',
|
||||
userManagementMenuOpen: true,
|
||||
currentUser: currentUser || null,
|
||||
message: message || '',
|
||||
messageVariant: messageVariant || 'success',
|
||||
roles: Array.isArray(roles) ? roles : [],
|
||||
formValues: formValues || {}
|
||||
});
|
||||
};
|
||||
+264
-48
@@ -1,7 +1,9 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>My account</h2>
|
||||
<p>Update your username, email, display name, and password.</p>
|
||||
<div class="d-flex flex-wrap align-items-start justify-content-between gap-3">
|
||||
<div>
|
||||
<h2>My account</h2>
|
||||
<p>Manage your sign-in identity, password, and active sessions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +11,7 @@
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Account details</h3>
|
||||
<h3 class="card-title">Profile and email</h3>
|
||||
</div>
|
||||
<form id="account-profile-form" method="post" action="/account/profile" autocomplete="off" data-async-save data-async-save-refresh-page="true">
|
||||
<div class="card-body">
|
||||
@@ -17,23 +19,276 @@
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="account-username" class="form-label">Username</label>
|
||||
<input id="account-username" type="text" name="username" class="form-control" value="{{username}}" autocomplete="username" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" maxlength="64" required />
|
||||
<div class="form-text">Used to sign in with your password.</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="account-name" class="form-label">Display name</label>
|
||||
<input id="account-name" type="text" name="name" class="form-control" value="{{name}}" autocomplete="name" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" required />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="account-email" class="form-label">Email address</label>
|
||||
<input id="account-email" type="email" name="email" class="form-control" value="{{email}}" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" />
|
||||
<div class="form-text">{{#if emailPending}}Pending verification for {{pendingEmail}}. Enter a different address here to replace it, or resend the verification email below.{{else}}{{#if emailVerified}}Verified{{else}}Not verified{{/if}}. Email is optional; changing the address requires confirmation by email.{{/if}}</div>
|
||||
<label for="account-email" class="form-label d-flex align-items-center justify-content-between gap-2">
|
||||
<span>Email address</span>
|
||||
<span class="small {{#if emailPending}}text-warning{{else}}{{#if emailVerified}}text-success{{else}}text-body-secondary{{/if}}{{/if}}">{{#if emailPending}}{{#if pendingEmailChange}}Change pending{{else}}Verification pending{{/if}}{{else}}{{#if emailVerified}}Verified{{else}}Not verified{{/if}}{{/if}}</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input id="account-email" type="email" name="email" class="form-control" value="{{email}}" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" />
|
||||
{{#if emailPending}}
|
||||
<button type="submit" class="btn btn-outline-primary" form="account-email-verification-form">Resend code</button>
|
||||
{{else}}
|
||||
{{#unless emailVerified}}
|
||||
{{#if email}}<button type="submit" class="btn btn-outline-primary" form="account-email-verification-form">Send code</button>{{/if}}
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="form-text">{{#if emailPending}}A code was sent to {{pendingEmail}}. Enter it below to verify the address.{{else}}Used for email verification and password recovery. Changing the address requires a code.{{/if}}</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="account-profile-current-password" class="form-label">Current password</label>
|
||||
<input id="account-profile-current-password" type="password" name="current_password" class="form-control" autocomplete="current-password" required />
|
||||
<div class="form-text">Confirm your password to update your profile.</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="current_password" data-account-profile-current-password />
|
||||
</div>
|
||||
</form>
|
||||
{{#if emailPending}}<form method="post" action="/account/email" class="px-3 pb-3"><input type="hidden" name="email" value="{{pendingEmail}}"><button type="submit" class="btn btn-sm btn-outline-primary">Send verification email</button></form>{{/if}}
|
||||
{{#if emailPending}}
|
||||
<form id="account-email-verification-form" method="post" action="/account/email" hidden></form>
|
||||
{{else}}
|
||||
{{#if email}}
|
||||
{{#unless emailVerified}}<form id="account-email-verification-form" method="post" action="/account/email/send-verification" hidden></form>{{/unless}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-success" form="account-profile-form" name="save_action" value="save">Save account details</button>
|
||||
</div>
|
||||
{{#if emailPending}}
|
||||
<div class="card-body border-top pt-3">
|
||||
<form method="post" action="/account/email/verify" class="mb-0">
|
||||
<label for="account-email-otp" class="form-label">Email code</label>
|
||||
<div class="input-group">
|
||||
<input id="account-email-otp" name="otp" type="text" class="form-control" inputmode="numeric" autocomplete="one-time-code" required />
|
||||
<button type="submit" class="btn btn-primary">Verify email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if canManageTwoFactor}}
|
||||
<div class="card card-outline card-info admin-form-card mt-3">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Sign-in methods</h3>
|
||||
</div>
|
||||
{{#if requiresTwoFactor}}
|
||||
{{#unless twoFactorEnabled}}
|
||||
<div class="alert alert-warning m-3 mb-0" role="alert">Two-factor authentication is required by one of your roles. Set up an authenticator to continue.</div>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
<div class="card-body py-0" data-passkey-rp-id="{{passkeyRpID}}">
|
||||
<ul class="list-group list-group-flush">
|
||||
{{#if authMethods.length}}
|
||||
{{#each authMethods}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
|
||||
<span>{{label}}</span>
|
||||
<span class="small text-success">Linked</span>
|
||||
</li>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
|
||||
<span>No linked sign-in methods</span>
|
||||
<span class="small text-body-secondary">None</span>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if twoFactorEnabled}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
|
||||
<span>Authenticator app (TOTP)</span>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<span class="small text-success">Enabled</span>
|
||||
<button type="button" class="btn btn-sm {{#if requiresTwoFactor}}btn-outline-secondary{{else}}btn-outline-danger{{/if}}" data-bs-toggle="modal" data-bs-target="#two-factor-manage-modal">{{#if requiresTwoFactor}}Manage{{else}}Unlink{{/if}}</button>
|
||||
</span>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
|
||||
<span>Two-step verification</span>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{{#if requiresTwoFactor}}<span class="small text-warning">Required by role</span>{{/if}}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#two-factor-setup-modal">Set up</button>
|
||||
</span>
|
||||
</li>
|
||||
{{/if}}
|
||||
<li class="list-group-item px-0" data-passkey-group>
|
||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
||||
<span>Passkeys</span>
|
||||
{{#if requiresTwoFactor}}
|
||||
<span class="small text-body-secondary">Unavailable while an authenticator is required</span>
|
||||
{{else if twoFactorEnabled}}
|
||||
<span class="small text-body-secondary">Unavailable while authenticator verification is enabled</span>
|
||||
{{else}}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" data-passkey-add-trigger data-bs-toggle="modal" data-bs-target="#passkey-setup-modal">Set up</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if passkeys.length}}
|
||||
<ul class="list-group list-group-flush ms-2 mt-1 ps-2" data-passkey-list>
|
||||
{{#each passkeys}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 border-0 px-0 py-1 lh-sm">
|
||||
<span><i class="bi bi-arrow-return-right me-2 text-body-secondary" aria-hidden="true"></i>{{#if name}}{{name}}{{else}}Passkey{{/if}}</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" data-passkey-delete="{{id}}" aria-label="Remove passkey">Remove</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="form-text mx-3 mb-3" data-passkey-unavailable hidden>Passkeys require HTTPS and the configured Pulse hostname.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#unless twoFactorEnabled}}
|
||||
{{#unless requiresTwoFactor}}
|
||||
<div class="modal fade" id="passkey-setup-modal" tabindex="-1" aria-labelledby="passkey-setup-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title fs-5" id="passkey-setup-title">Set up a passkey</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Use your device biometrics, device PIN, or a security key. Your browser will guide you through setup.</p>
|
||||
<form data-passkey-add>
|
||||
<label for="passkey-name" class="form-label">Passkey name</label>
|
||||
<div class="input-group">
|
||||
<input id="passkey-name" name="name" type="text" class="form-control" maxlength="255" placeholder="For example, Work laptop" autocomplete="off" />
|
||||
<button type="submit" class="btn btn-primary" data-passkey-submit><i class="bi bi-fingerprint me-1" aria-hidden="true"></i>Continue</button>
|
||||
</div>
|
||||
<div class="form-text" data-passkey-progress hidden>Follow the prompt from your browser or device to finish adding the passkey.</div>
|
||||
<div class="alert alert-warning mt-2 mb-0" data-passkey-feedback role="alert" hidden></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/unless}}
|
||||
{{/unless}}
|
||||
|
||||
{{#unless twoFactorEnabled}}
|
||||
<div class="modal fade" id="two-factor-setup-modal" tabindex="-1" aria-labelledby="two-factor-setup-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title fs-5" id="two-factor-setup-title">Set up two-step verification</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form data-two-factor-enable>
|
||||
<label for="two-factor-enable-password" class="form-label">Current password</label>
|
||||
<div class="input-group">
|
||||
<input id="two-factor-enable-password" name="password" type="password" class="form-control" autocomplete="current-password" required />
|
||||
<button type="submit" class="btn btn-primary">Continue</button>
|
||||
</div>
|
||||
<div class="form-text">You’ll scan a QR code with your authenticator app, save recovery codes, then verify a code to finish.</div>
|
||||
<div class="alert alert-warning mt-2" data-two-factor-enable-feedback hidden role="alert"></div>
|
||||
</form>
|
||||
<div data-two-factor-setup hidden>
|
||||
<p class="fw-semibold">Scan this QR code with your authenticator app</p>
|
||||
<div class="d-flex flex-column flex-sm-row align-items-start gap-3">
|
||||
<div class="p-1 bg-white border rounded" data-two-factor-qr></div>
|
||||
<div>
|
||||
<p class="small mb-1">Or enter this setup key manually:</p>
|
||||
<code class="user-select-all" data-two-factor-secret></code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning mt-3" data-two-factor-codes hidden>
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 mb-2">
|
||||
<p class="fw-semibold mb-0">Save these recovery codes before verifying. Each code can only be used once.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary flex-shrink-0" data-two-factor-download aria-label="Download recovery codes" title="Download recovery codes"><i class="bi bi-download" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<ul class="mb-2 font-monospace" data-two-factor-codes-list></ul>
|
||||
</div>
|
||||
<form data-two-factor-verify class="mt-3">
|
||||
<label for="two-factor-setup-code" class="form-label">Authenticator code</label>
|
||||
<div class="input-group">
|
||||
<input id="two-factor-setup-code" name="code" type="text" class="form-control" inputmode="numeric" autocomplete="one-time-code" required />
|
||||
<button type="submit" class="btn btn-primary">Verify and enable</button>
|
||||
</div>
|
||||
<div class="alert alert-warning mt-2" data-two-factor-verify-feedback hidden role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/unless}}
|
||||
|
||||
{{#if twoFactorEnabled}}
|
||||
<div class="modal fade" id="two-factor-manage-modal" tabindex="-1" aria-labelledby="two-factor-manage-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title fs-5" id="two-factor-manage-title">Manage authenticator</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{#unless requiresTwoFactor}}
|
||||
<form data-two-factor-disable>
|
||||
<p>Unlinking removes the authenticator and recovery codes. You will need to sign in again.</p>
|
||||
<label for="two-factor-disable-password" class="form-label">Current password</label>
|
||||
<div class="input-group">
|
||||
<input id="two-factor-disable-password" name="password" type="password" class="form-control" autocomplete="current-password" required />
|
||||
<button type="submit" class="btn btn-outline-danger">Unlink</button>
|
||||
</div>
|
||||
<div class="alert alert-warning mt-2" data-two-factor-disable-feedback hidden role="alert"></div>
|
||||
</form>
|
||||
{{/unless}}
|
||||
<hr>
|
||||
<form data-two-factor-regenerate-codes>
|
||||
<label for="two-factor-codes-password" class="form-label">Generate new recovery codes</label>
|
||||
<div class="input-group">
|
||||
<input id="two-factor-codes-password" name="password" type="password" class="form-control" autocomplete="current-password" placeholder="Current password" required />
|
||||
<button type="submit" class="btn btn-outline-primary">Generate</button>
|
||||
</div>
|
||||
<div class="form-text">Generating new codes invalidates all previously issued recovery codes.</div>
|
||||
</form>
|
||||
<div class="alert alert-success mt-3" data-two-factor-codes hidden>
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 mb-2">
|
||||
<p class="fw-semibold mb-0">Store these recovery codes somewhere safe. Each code can only be used once.</p>
|
||||
<button type="button" class="btn btn-sm btn-outline-success flex-shrink-0" data-two-factor-download aria-label="Download recovery codes" title="Download recovery codes"><i class="bi bi-download" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<ul class="mb-2 font-monospace" data-two-factor-codes-list></ul>
|
||||
</div>
|
||||
<div class="alert alert-warning mt-2" data-two-factor-codes-feedback hidden role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card card-outline card-warning admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Change password</h3>
|
||||
</div>
|
||||
<form id="account-password-form" method="post" action="/account/password" autocomplete="off" data-async-save data-async-save-refresh-page="true">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="account-password-current-password" class="form-label">Current password</label>
|
||||
<input id="account-password-current-password" type="password" name="current_password" class="form-control" autocomplete="current-password" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="account-new-password" class="form-label">New password</label>
|
||||
<input id="account-new-password" type="password" name="new_password" class="form-control" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" minlength="{{passwordMinimumLength}}" required />
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="account-confirm-password" class="form-label">Confirm new password</label>
|
||||
<input id="account-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" minlength="{{passwordMinimumLength}}" required />
|
||||
</div>
|
||||
<div class="form-text mt-2">{{passwordRequirementsText}}</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-warning" form="account-password-form" name="save_action" value="save">Change password</button>
|
||||
</div>
|
||||
</div>
|
||||
{{#if allowUserSessionRevocation}}
|
||||
<div class="card card-outline card-secondary admin-form-card mt-3">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
@@ -71,43 +326,4 @@
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card card-outline card-warning admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Password</h3>
|
||||
</div>
|
||||
<form id="account-password-form" method="post" action="/account/password" autocomplete="off" data-async-save data-async-save-refresh-page="true">
|
||||
<div class="card-body">
|
||||
<input type="hidden" name="current_password" data-account-password-current-password />
|
||||
<div class="mb-3">
|
||||
<label for="account-new-password" class="form-label">New password</label>
|
||||
<input id="account-new-password" type="password" name="new_password" class="form-control" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" minlength="{{passwordMinimumLength}}" required />
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="account-confirm-password" class="form-label">Confirm new password</label>
|
||||
<input id="account-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" minlength="{{passwordMinimumLength}}" required />
|
||||
</div>
|
||||
<div class="form-text mt-2">{{passwordRequirementsText}}</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card card-outline card-secondary admin-form-card mt-3">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Confirmation</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<label for="account-current-password" class="form-label">Current password</label>
|
||||
<input id="account-current-password" type="password" class="form-control" autocomplete="current-password" data-account-current-password required />
|
||||
<div class="form-text">Required to save account details or change your password.</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Account change actions">
|
||||
<button type="submit" class="btn btn-success" form="account-profile-form" name="save_action" value="save">Save account details</button>
|
||||
<button type="submit" class="btn btn-warning" form="account-password-form" name="save_action" value="save">Change password</button>
|
||||
<a class="btn btn-secondary" href="{{returnUrl}}" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,27 +0,0 @@
|
||||
<div class="login-logo text-center mb-3"><strong>Pulse</strong> Signage</div>
|
||||
{{#if isValidInvitation}}
|
||||
<div class="card mx-auto" style="max-width: 30rem;"><div class="card-body p-4">
|
||||
{{#if message}}<div class="alert alert-warning">{{message}}</div>{{/if}}
|
||||
<form method="post" action="/accept-invite">
|
||||
<input type="hidden" name="token" value="{{token}}">
|
||||
<div class="mb-3"><label for="invite-email" class="form-label">Email address</label><input id="invite-email" type="email" class="form-control" value="{{email}}" disabled></div>
|
||||
<div class="mb-3"><label for="invite-username" class="form-label">Username</label><input id="invite-username" name="username" type="text" class="form-control" autocomplete="username" maxlength="255" required></div>
|
||||
<div class="mb-3"><label for="invite-name" class="form-label">Display name</label><input id="invite-name" name="name" type="text" class="form-control" value="{{name}}" autocomplete="name" maxlength="255" required></div>
|
||||
<div class="mb-3"><label for="invite-password" class="form-label">Password</label><input id="invite-password" name="password" type="password" class="form-control" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required></div>
|
||||
<div class="mb-3"><label for="invite-confirm-password" class="form-label">Confirm password</label><input id="invite-confirm-password" name="confirm_password" type="password" class="form-control" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required></div>
|
||||
<div class="form-text mb-3">{{passwordRequirementsText}}</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Create account</button>
|
||||
</form>
|
||||
</div></div>
|
||||
{{else}}
|
||||
<div class="card login-card email-verification-error-card mx-auto">
|
||||
<div class="email-verification-error-card__header">
|
||||
<div class="email-verification-error-card__icon"><i class="bi bi-exclamation-lg" aria-hidden="true"></i></div>
|
||||
<h1 class="h4 mb-0">Invitation link unavailable</h1>
|
||||
</div>
|
||||
<div class="card-body login-card-body text-center">
|
||||
<p class="mb-4">{{message}}</p>
|
||||
<a href="/login" class="btn btn-primary w-100">Return to sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
@@ -1,11 +0,0 @@
|
||||
<div class="login-logo text-center mb-3"><strong>Pulse</strong> Signage</div>
|
||||
<div class="card login-card email-verification-error-card mx-auto">
|
||||
<div class="email-verification-error-card__header">
|
||||
<div class="email-verification-error-card__icon"><i class="bi bi-exclamation-lg" aria-hidden="true"></i></div>
|
||||
<h1 class="h4 mb-0">Verification link unavailable</h1>
|
||||
</div>
|
||||
<div class="card-body login-card-body text-center">
|
||||
<p class="mb-4">{{message}}</p>
|
||||
<a href="/login" class="btn btn-primary w-100">Return to sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,11 +0,0 @@
|
||||
<div class="login-logo text-center mb-3"><strong>Pulse</strong> Signage</div>
|
||||
<div class="card login-card email-verified-card mx-auto">
|
||||
<div class="email-verified-card__header">
|
||||
<div class="email-verified-card__icon"><i class="bi bi-check-lg" aria-hidden="true"></i></div>
|
||||
<h1 class="h4 mb-0">Email verified</h1>
|
||||
</div>
|
||||
<div class="card-body login-card-body text-center">
|
||||
<p class="mb-4">Your email address has been verified successfully.</p>
|
||||
<a href="/login" class="btn btn-primary w-100">Continue to sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,7 +5,7 @@
|
||||
<form method="post" action="/forgot-password">
|
||||
<label for="reset-identity" class="form-label">Username or email</label>
|
||||
<input id="reset-identity" name="identity" type="text" class="form-control mb-3" autocomplete="username" required>
|
||||
<button type="submit" class="btn btn-primary w-100">Email reset link</button>
|
||||
<button type="submit" class="btn btn-primary w-100">Send reset code</button>
|
||||
</form>
|
||||
<a class="d-block text-center mt-3" href="/login">Back to sign in</a>
|
||||
</div></div>
|
||||
@@ -1,44 +1,63 @@
|
||||
<div class="login-logo text-center mb-3">
|
||||
<strong>Pulse</strong> Signage
|
||||
<a class="text-reset text-decoration-none" href="/login"><strong>Pulse</strong> Signage</a>
|
||||
</div>
|
||||
<div class="card login-card mx-auto">
|
||||
<div class="card-body login-card-body">
|
||||
<p class="login-box-msg">Sign in to start your session</p>
|
||||
{{#if message}}
|
||||
<div class="alert alert-warning py-2">{{message}}</div>
|
||||
<div class="alert alert-warning py-2" data-login-initial-message>{{message}}</div>
|
||||
{{/if}}
|
||||
<form method="post" action="/login">
|
||||
<form method="post" action="/api/auth/sign-in/username" data-better-auth-login>
|
||||
{{#if returnTo}}
|
||||
<input type="hidden" name="returnTo" value="{{returnTo}}" />
|
||||
{{/if}}
|
||||
<div class="mb-3">
|
||||
<label for="username" class="visually-hidden">Username or email</label>
|
||||
<div class="input-group">
|
||||
<input id="username" name="username" type="text" class="form-control" placeholder="Username or email" autocomplete="username" value="{{username}}" required />
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-person"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="visually-hidden">Password</label>
|
||||
<div class="input-group">
|
||||
<input id="password" name="password" type="password" class="form-control" placeholder="Password" autocomplete="current-password" required />
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-lock"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row align-items-center g-3 mb-3">
|
||||
<div class="col-7">
|
||||
<div class="form-check">
|
||||
<input id="remember-me" name="remember_me" type="checkbox" class="form-check-input" value="1" />
|
||||
<label for="remember-me" class="form-check-label">Remember me</label>
|
||||
<div data-login-credentials>
|
||||
<div class="mb-3">
|
||||
<label for="username" class="visually-hidden">Username or email</label>
|
||||
<div class="input-group">
|
||||
<input id="username" name="username" type="text" class="form-control" placeholder="Username or email" autocomplete="username" value="{{username}}" required />
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-person"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<button type="submit" class="btn btn-primary w-100">Sign in</button>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="visually-hidden">Password</label>
|
||||
<div class="input-group">
|
||||
<input id="password" name="password" type="password" class="form-control" placeholder="Enter your password" autocomplete="current-password" required />
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-lock"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" data-login-two-factor hidden>
|
||||
<p class="mb-2 fw-semibold">Two-factor authentication</p>
|
||||
<p class="small text-body-secondary">Enter a code from your authenticator app or use a recovery code.</p>
|
||||
<label for="two-factor-code" class="visually-hidden" data-two-factor-code-label>Authenticator code</label>
|
||||
<input id="two-factor-code" name="two_factor_code" type="text" class="form-control mb-2" inputmode="numeric" autocomplete="one-time-code" disabled />
|
||||
<div class="form-check mb-2">
|
||||
<input id="trust-device" name="trust_device" type="checkbox" class="form-check-input" value="1" disabled />
|
||||
<label for="trust-device" class="form-check-label">Trust this device for 30 days</label>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between gap-2">
|
||||
<button type="button" class="btn btn-sm btn-link p-0" data-two-factor-toggle-method>Use recovery code</button>
|
||||
<button type="button" class="btn btn-sm btn-link p-0" data-two-factor-back>Back to sign in</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning py-2" data-login-feedback role="alert" hidden></div>
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mb-3" data-login-actions>
|
||||
<div data-login-remember>
|
||||
<div class="form-check">
|
||||
<input id="remember-me" name="remember_me" type="checkbox" class="form-check-input" value="1" />
|
||||
<label for="remember-me" class="form-check-label">Remember Me</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary px-4" data-login-submit>Sign In</button>
|
||||
</div>
|
||||
<div data-passkey-login-panel data-passkey-rp-id="{{passkeyRpID}}">
|
||||
<div class="login-or-divider" aria-hidden="true"><span>OR</span></div>
|
||||
<button type="button" class="btn btn-secondary w-100" data-passkey-login><i class="bi bi-fingerprint me-1" aria-hidden="true"></i>Sign in with a passkey</button>
|
||||
<p class="form-text text-center mb-0 mt-2" data-passkey-unavailable hidden>Passkeys require HTTPS and the configured Pulse hostname.</p>
|
||||
</div>
|
||||
</form>
|
||||
{{#if showForgotPassword}}
|
||||
<p class="mb-0 text-center"><a href="/forgot-password">I forgot my password</a></p>
|
||||
<p class="mb-0 mt-3"><a href="/forgot-password">I forgot my password</a></p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,7 +3,10 @@
|
||||
<h1 class="h4 mb-3">Choose a new password</h1>
|
||||
{{#if message}}<div class="alert alert-warning">{{message}}</div>{{/if}}
|
||||
<form method="post" action="/reset-password">
|
||||
<input type="hidden" name="token" value="{{token}}">
|
||||
<label for="reset-email" class="form-label">Email address</label>
|
||||
<input id="reset-email" name="email" type="email" class="form-control mb-3" autocomplete="email" value="{{email}}" required>
|
||||
<label for="reset-otp" class="form-label">Email code</label>
|
||||
<input id="reset-otp" name="otp" type="text" class="form-control mb-3" autocomplete="one-time-code" inputmode="numeric" required>
|
||||
<label for="reset-password" class="form-label">New password</label>
|
||||
<input id="reset-password" name="password" type="password" class="form-control mb-3" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required>
|
||||
<label for="reset-confirm-password" class="form-label">Confirm password</label>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Pending invitations</h2>
|
||||
<p>Invitations that have not yet been accepted.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="card-title">Active invitations</h3>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search invitations" aria-label="Search invitations" data-table-search />
|
||||
</div>
|
||||
{{#if (hasPermission currentUser 'invitations.create')}}
|
||||
<a href="/settings/users/invite" class="btn btn-info btn-sm">Invite user</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-table-sort-key="email">Email</th>
|
||||
<th data-table-sort-key="name">Name</th>
|
||||
<th>Roles</th>
|
||||
<th data-table-sort-key="created">Sent</th>
|
||||
<th data-table-sort-key="expires">Expires</th>
|
||||
<th data-table-sort-key="createdBy">Sent by</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if invitations.length}}
|
||||
{{#each invitations}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Email">{{email}}</td>
|
||||
<td data-label="Name">{{#if name}}{{name}}{{else}}-{{/if}}</td>
|
||||
<td data-label="Roles">{{roleNames}}</td>
|
||||
<td data-label="Sent">{{createdAtLabel}}</td>
|
||||
<td data-label="Expires">{{expiresAtLabel}}</td>
|
||||
<td data-label="Sent by">{{createdByLabel}}</td>
|
||||
<td data-label="Actions">
|
||||
<div class="actions users-row-actions">
|
||||
{{#if (hasPermission ../currentUser 'invitations.allow')}}
|
||||
<form method="post" action="/settings/invitations/{{id}}/resend" class="inline-form" data-confirm-message="Resend invitation to {{email}}?">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Resend</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'invitations.delete')}}
|
||||
<form method="post" action="/settings/invitations/{{id}}/delete" class="inline-form" data-confirm-message="Delete invitation for {{email}}?">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default><td colspan="7" class="empty">No pending invitations.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/settings/invitations" alwaysShow=true}}
|
||||
</div>
|
||||
@@ -24,6 +24,10 @@
|
||||
<label for="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" class="form-label">Description</label>
|
||||
<textarea id="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" name="description" class="form-control" rows="5" maxlength="512" data-limit-text-length>{{#if isEdit}}{{role.description}}{{else}}{{formValues.description}}{{/if}}</textarea>
|
||||
</div>
|
||||
<div class="form-check form-switch mt-3">
|
||||
<input id="role-require-two-factor" name="require_two_factor" type="checkbox" class="form-check-input" value="1" {{#if isEdit}}{{#if role.require_two_factor}}checked{{/if}}{{else}}{{#if formValues.require_two_factor}}checked{{/if}}{{/if}} />
|
||||
<label class="form-check-label" for="role-require-two-factor">Require two-factor authentication</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end flex-wrap">
|
||||
<div class="btn-group" role="group" aria-label="Role actions">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>System Settings</h2>
|
||||
<h2>System settings</h2>
|
||||
<p>Configure the defaults and content policies used across Pulse Signage.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,9 +282,7 @@
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-smtp-password">SMTP password</label><input id="settings-smtp-password" name="smtp_password" type="password" class="form-control" placeholder="{{#if mediaSettings.smtpConfigured}}Leave unchanged{{/if}}"></div>
|
||||
<div class="col-12 col-md-6"><label class="form-label" for="settings-from-address">From address</label><input id="settings-from-address" name="from_address" type="email" class="form-control" value="{{mediaSettings.emailFromAddress}}"></div>
|
||||
<div class="col-12 col-md-6"><label class="form-label" for="settings-from-name">From name</label><input id="settings-from-name" name="from_name" type="text" class="form-control" value="{{mediaSettings.emailFromName}}"></div>
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-verification-expiry">Verification expiry</label><div class="input-group"><input id="settings-verification-expiry" name="verification_expiry_minutes" type="number" class="form-control" min="1" max="10080" value="{{mediaSettings.verificationExpiryMinutes}}"><span class="input-group-text">minutes</span></div></div>
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-reset-expiry">Password reset expiry</label><div class="input-group"><input id="settings-reset-expiry" name="reset_expiry_minutes" type="number" class="form-control" min="1" max="10080" value="{{mediaSettings.resetExpiryMinutes}}"><span class="input-group-text">minutes</span></div></div>
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-invitation-expiry">Invitation expiry</label><div class="input-group"><input id="settings-invitation-expiry" name="invitation_expiry_hours" type="number" class="form-control" min="1" max="720" value="{{mediaSettings.invitationExpiryHours}}"><span class="input-group-text">hours</span></div></div>
|
||||
<div class="col-12 col-md-6"><label class="form-label" for="settings-otp-expiry">Email code expiry</label><div class="input-group"><input id="settings-otp-expiry" name="otp_expiry_minutes" type="number" class="form-control" min="1" max="10080" value="{{mediaSettings.otpExpiryMinutes}}"><span class="input-group-text">minutes</span></div></div>
|
||||
</div></div>
|
||||
<div class="card-footer d-flex align-items-center">
|
||||
{{#if (hasPermission currentUser 'system-settings.update')}}
|
||||
@@ -303,7 +301,7 @@
|
||||
<div id="email-templates" class="card settings-section-card" data-settings-section hidden>
|
||||
<div class="card-header"><h3 class="h5 mb-1 fw-semibold"><i class="bi bi-envelope-paper me-2 text-primary" aria-hidden="true"></i>Email templates</h3><p class="text-muted small mb-0">Customize the messages sent for email verification and password resets.</p></div>
|
||||
<div class="card-body">
|
||||
<div data-email-preview-user data-username="{{currentUser.username}}" data-display-name="{{currentUser.name}}" data-email="{{currentUser.email}}" data-verification-expiry="{{mediaSettings.verificationExpiryMinutes}}" data-reset-expiry="{{mediaSettings.resetExpiryMinutes}}" data-invitation-expiry="{{mediaSettings.invitationExpiryHours}}" hidden></div>
|
||||
<div data-email-preview-user data-username="{{currentUser.username}}" data-display-name="{{currentUser.name}}" data-email="{{currentUser.email}}" data-otp-expiry="{{mediaSettings.otpExpiryMinutes}}" hidden></div>
|
||||
<div class="email-template-editor" data-email-template-editor>
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-12 col-lg-6">
|
||||
@@ -313,11 +311,7 @@
|
||||
<label class="form-label" for="settings-verification-body">Message</label>
|
||||
<div class="btn-toolbar mb-2" role="toolbar" aria-label="Verification message formatting"><div class="btn-group btn-group-sm" role="group"><button type="button" class="btn btn-outline-secondary" data-email-format="b"><strong>B</strong></button><button type="button" class="btn btn-outline-secondary" data-email-format="i"><em>I</em></button><button type="button" class="btn btn-outline-secondary" data-email-format="u"><u>U</u></button></div></div>
|
||||
<textarea id="settings-verification-body" name="verification_body" class="form-control" rows="5" data-email-template-body>{{mediaSettings.verificationEmailBody}}</textarea>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-12 col-md-8"><label class="form-label" for="settings-verification-button-text">Button text</label><input id="settings-verification-button-text" name="verification_button_text" type="text" class="form-control" value="{{mediaSettings.verificationButtonText}}" maxlength="120" data-email-template-button-text></div>
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-verification-alignment">Alignment</label><select id="settings-verification-alignment" name="verification_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.verificationButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.verificationButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.verificationButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
|
||||
</div>
|
||||
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the verification link. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
|
||||
<div class="form-text">Use <code>[[otp]]</code> for the one-time code. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="email-template-preview" data-email-template-preview aria-label="Email verification preview"></div>
|
||||
@@ -334,36 +328,13 @@
|
||||
<label class="form-label" for="settings-reset-body">Message</label>
|
||||
<div class="btn-toolbar mb-2" role="toolbar" aria-label="Reset message formatting"><div class="btn-group btn-group-sm" role="group"><button type="button" class="btn btn-outline-secondary" data-email-format="b"><strong>B</strong></button><button type="button" class="btn btn-outline-secondary" data-email-format="i"><em>I</em></button><button type="button" class="btn btn-outline-secondary" data-email-format="u"><u>U</u></button></div></div>
|
||||
<textarea id="settings-reset-body" name="reset_body" class="form-control" rows="5" data-email-template-body>{{mediaSettings.resetEmailBody}}</textarea>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-12 col-md-8"><label class="form-label" for="settings-reset-button-text">Button text</label><input id="settings-reset-button-text" name="reset_button_text" type="text" class="form-control" value="{{mediaSettings.resetButtonText}}" maxlength="120" data-email-template-button-text></div>
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-reset-alignment">Alignment</label><select id="settings-reset-alignment" name="reset_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.resetButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.resetButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.resetButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
|
||||
</div>
|
||||
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the reset link. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
|
||||
<div class="form-text">Use <code>[[otp]]</code> for the one-time code. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="email-template-preview" data-email-template-preview aria-label="Password reset preview"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-4">
|
||||
<div class="email-template-editor" data-email-template-editor>
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-12 col-lg-6">
|
||||
<h4 class="h6">User invitation</h4>
|
||||
<label class="form-label" for="settings-invitation-subject">Subject</label>
|
||||
<input id="settings-invitation-subject" name="invitation_subject" type="text" class="form-control mb-2" value="{{mediaSettings.invitationEmailSubject}}" maxlength="255" data-email-template-subject>
|
||||
<label class="form-label" for="settings-invitation-body">Message</label>
|
||||
<div class="btn-toolbar mb-2" role="toolbar" aria-label="Invitation message formatting"><div class="btn-group btn-group-sm" role="group"><button type="button" class="btn btn-outline-secondary" data-email-format="b"><strong>B</strong></button><button type="button" class="btn btn-outline-secondary" data-email-format="i"><em>I</em></button><button type="button" class="btn btn-outline-secondary" data-email-format="u"><u>U</u></button></div></div>
|
||||
<textarea id="settings-invitation-body" name="invitation_body" class="form-control" rows="5" data-email-template-body>{{mediaSettings.invitationEmailBody}}</textarea>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-12 col-md-8"><label class="form-label" for="settings-invitation-button-text">Button text</label><input id="settings-invitation-button-text" name="invitation_button_text" type="text" class="form-control" value="{{mediaSettings.invitationButtonText}}" maxlength="120" data-email-template-button-text></div>
|
||||
<div class="col-12 col-md-4"><label class="form-label" for="settings-invitation-alignment">Alignment</label><select id="settings-invitation-alignment" name="invitation_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.invitationButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.invitationButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.invitationButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
|
||||
</div>
|
||||
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the invitation link. Available values: <code>[[display_name]]</code>, <code>[[email]]</code>, <code>[[expiry_time]]</code>.</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6"><div class="email-template-preview" data-email-template-preview aria-label="User invitation preview"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
{{#if (hasPermission currentUser 'system-settings.update')}}<button type="submit" class="btn btn-success">Save</button>{{else}}<button type="button" class="btn btn-outline-success" disabled>Save</button>{{/if}}
|
||||
@@ -371,6 +342,9 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{#if (hasPermission currentUser 'login-lockouts.allow')}}
|
||||
<form id="reset-login-lockouts-form" method="post" action="/settings/system/login-lockouts/reset" data-confirm-message="Clear sign-in and two-factor request rate limits for all clients? This does not clear per-account authenticator lockouts."></form>
|
||||
{{/if}}
|
||||
<form method="post" action="/settings/system" data-async-save data-settings-form="security">
|
||||
<input type="hidden" name="settings_section" value="security">
|
||||
<div id="security-sessions" class="card settings-section-card" data-settings-section hidden>
|
||||
@@ -383,22 +357,6 @@
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12"><h4 class="h6 text-uppercase text-muted mb-0">Sessions</h4></div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="settings-session-lifetime">Session lifetime</label>
|
||||
<div class="input-group">
|
||||
<input id="settings-session-lifetime" name="session_lifetime_days" type="number" class="form-control" min="1" max="365" required value="{{mediaSettings.sessionLifetimeDays}}">
|
||||
<span class="input-group-text">days</span>
|
||||
</div>
|
||||
<div class="form-text">Applies to new logins. Existing sessions keep their current expiry.</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="settings-max-active-sessions">Maximum active sessions</label>
|
||||
<div class="input-group">
|
||||
<input id="settings-max-active-sessions" name="max_active_sessions" type="number" class="form-control" min="0" max="100" required value="{{mediaSettings.maxActiveSessions}}">
|
||||
<span class="input-group-text">sessions</span>
|
||||
</div>
|
||||
<div class="form-text">Use 0 for unlimited. Oldest sessions are removed first.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input id="settings-allow-session-revocation" name="allow_user_session_revocation" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.allowUserSessionRevocation}}checked{{/if}}>
|
||||
@@ -406,26 +364,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Login protection</h4></div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="settings-login-attempts">Failed attempts before lockout</label>
|
||||
<input id="settings-login-attempts" name="login_max_attempts" type="number" class="form-control" min="1" max="100" required value="{{mediaSettings.loginMaxAttempts}}">
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="settings-login-lockout">Lockout duration</label>
|
||||
<div class="input-group">
|
||||
<input id="settings-login-lockout" name="login_lockout_minutes" type="number" class="form-control" min="1" max="1440" required value="{{mediaSettings.loginLockoutMinutes}}">
|
||||
<span class="input-group-text">minutes</span>
|
||||
<div class="col-12"><p class="form-text mb-0">Built-in request protection allows up to 30 requests per 15 minutes, with username sign-in limited to 5 per 15 minutes.</p></div>
|
||||
{{#if (hasPermission currentUser 'login-lockouts.allow')}}
|
||||
<div class="col-12">
|
||||
<button type="submit" form="reset-login-lockouts-form" class="btn btn-sm btn-outline-warning"><i class="bi bi-unlock me-1" aria-hidden="true"></i>Reset sign-in and two-factor request limits</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label" for="settings-login-scope">Track failed logins by</label>
|
||||
<select id="settings-login-scope" name="login_rate_limit_scope" class="form-select" required>
|
||||
<option value="both" {{#if (eq mediaSettings.loginRateLimitScope 'both')}}selected{{/if}}>Username and IP address</option>
|
||||
<option value="username" {{#if (eq mediaSettings.loginRateLimitScope 'username')}}selected{{/if}}>Username</option>
|
||||
<option value="ip" {{#if (eq mediaSettings.loginRateLimitScope 'ip')}}selected{{/if}}>IP address</option>
|
||||
</select>
|
||||
<div class="form-text">Failed login responses remain generic.</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="col-12"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Password and account protection</h4></div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<label class="form-label" for="settings-password-min-length">Minimum length</label>
|
||||
@@ -450,13 +394,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input id="settings-require-password-new" name="require_password_change_for_new_users" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.requirePasswordChangeForNewUsers}}checked{{/if}}>
|
||||
<label class="form-check-label" for="settings-require-password-new">Require password change for newly created users</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input id="settings-require-password-reset" name="require_password_change_after_admin_reset" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.requirePasswordChangeAfterAdminReset}}checked{{/if}}>
|
||||
<label class="form-check-label" for="settings-require-password-reset">Require password change after an administrator reset</label>
|
||||
<input id="settings-admin-email-verification-bypass" name="allow_admin_email_verification_bypass" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.allowAdminEmailVerificationBypass}}checked{{/if}}>
|
||||
<label class="form-check-label" for="settings-admin-email-verification-bypass">Allow administrators to mark user email addresses as verified</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<form id="{{formId}}" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">{{#if isEdit}}Profile{{else}}User details{{/if}}</h3>
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="col-12">
|
||||
<label for="{{#if isEdit}}edit-user-email{{else}}user-email{{/if}}" class="form-label">Email address</label>
|
||||
<input id="{{#if isEdit}}edit-user-email{{else}}user-email{{/if}}" type="email" name="email" class="form-control" autocomplete="email" value="{{#if isEdit}}{{user.email}}{{else}}{{formValues.email}}{{/if}}" maxlength="320" />
|
||||
{{#if isEdit}}<div class="form-text">{{#if user.email_verified_at}}Verified{{else}}Not verified{{/if}}. Email is used for password recovery.</div>{{/if}}
|
||||
{{#if isEdit}}<div class="form-text">{{#if user.email_verified}}Verified{{else}}Not verified{{/if}}. Email is used for password recovery.</div>{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{#if isEdit}}
|
||||
@@ -39,19 +39,25 @@
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input id="edit-user-email-verified" name="email_verified" type="checkbox" class="form-check-input" value="1" autocomplete="off" {{#if user.email_verified_at}}checked{{/if}}>
|
||||
<input id="edit-user-email-verified" name="email_verified" type="checkbox" class="form-check-input" value="1" autocomplete="off" {{#if user.email_verified}}checked{{/if}}>
|
||||
<label class="form-check-label" for="edit-user-email-verified">Email verified</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="border-top mt-3 pt-3">
|
||||
<div class="form-check form-switch">
|
||||
<input id="user-must-change-password" name="must_change_password" type="checkbox" class="form-check-input" value="1" autocomplete="off" {{#if isEdit}}{{#if user.must_change_password}}checked{{/if}}{{else}}checked{{/if}}>
|
||||
<label class="form-check-label" for="user-must-change-password">Require password change at next sign-in</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div class="card-footer d-flex align-items-center justify-content-between flex-wrap">
|
||||
{{#if isEdit}}
|
||||
{{#if (hasPermission currentUser 'impersonate.allow')}}
|
||||
{{#unless currentUser.isViewingAs}}
|
||||
<button type="submit" form="view-as-user-form" class="btn btn-sm btn-outline-secondary"><i class="bi bi-person-badge me-1" aria-hidden="true"></i>View as</button>
|
||||
<button type="submit" form="view-as-user-form" class="btn btn btn-outline-secondary"><i class="bi bi-person-badge me-1" aria-hidden="true"></i>View as</button>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
@@ -76,40 +82,80 @@
|
||||
<input id="{{#if isEdit}}edit-user-confirm-password{{else}}user-confirm-password{{/if}}" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="10" {{#unless isEdit}}required{{/unless}} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text mt-2">Use at least 10 characters and include 3 of: uppercase, lowercase, a number, and a symbol.</div>
|
||||
<div class="form-text">Use at least 10 characters and include 3 of: uppercase, lowercase, a number, and a symbol.</div>
|
||||
{{#if isEdit}}
|
||||
<div class="form-text mt-2">Leave both fields blank to keep the current password.</div>
|
||||
<div class="form-text">Leave both fields blank to keep the current password.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if isEdit}}
|
||||
<div class="card card-outline card-secondary admin-form-card mt-3">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Sign-in methods</h3>
|
||||
</div>
|
||||
<div class="card-body py-0">
|
||||
<ul class="list-group list-group-flush">
|
||||
{{#each authMethods}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
|
||||
<span>{{label}}</span>
|
||||
<span class="small text-success">Linked</span>
|
||||
</li>
|
||||
{{/each}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 px-0">
|
||||
<span>{{#if user.twoFactorEnabled}}Authenticator app (TOTP){{else}}{{#if user.twoFactorSetupPending}}Authenticator setup{{else}}Two-step verification{{/if}}{{/if}}</span>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
{{#if user.twoFactorEnabled}}<span class="small text-success">Enabled</span>{{else}}{{#if user.twoFactorSetupPending}}<span class="small text-warning">Setup pending</span>{{else}}<span class="small text-body-secondary">Not enabled</span>{{/if}}{{/if}}
|
||||
{{#if user.canUnlinkTwoFactor}}<button type="submit" form="unlink-two-factor-form" class="btn btn-sm btn-outline-danger">Unlink</button>{{/if}}
|
||||
</span>
|
||||
</li>
|
||||
<li class="list-group-item px-0" data-admin-passkey-group>
|
||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
||||
<span>Passkeys</span>
|
||||
<span class="small text-body-secondary">{{#if user.passkeys.length}}{{user.passkeys.length}} registered{{else}}Not registered{{/if}}</span>
|
||||
</div>
|
||||
{{#if user.passkeys.length}}
|
||||
<ul class="list-group list-group-flush ms-2 mt-1 ps-2" data-admin-passkey-list>
|
||||
{{#each user.passkeys}}
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between gap-3 border-0 px-0 py-1 lh-sm">
|
||||
<span><i class="bi bi-arrow-return-right me-2 text-body-secondary" aria-hidden="true"></i>{{#if name}}{{name}}{{else}}Passkey{{/if}}</span>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<span class="small text-success">Linked</span>
|
||||
{{#if ../user.canUnlinkPasskeys}}<button type="submit" form="unlink-passkey-form-{{id}}" class="btn btn-sm btn-outline-danger">Unlink</button>{{/if}}
|
||||
</span>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card card-outline card-secondary admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Roles</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{{#if roles.length}}
|
||||
<div class="card-body py-0">
|
||||
{{#if roles.length}}
|
||||
<div class="list-group list-group-flush">
|
||||
{{#each roles}}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
|
||||
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="form-check-label pe-4">
|
||||
<strong>{{name}}</strong>
|
||||
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="list-group-item d-flex align-items-start gap-3 px-0">
|
||||
<input class="form-check-input mt-1 flex-shrink-0" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="flex-grow-1">
|
||||
<strong class="d-block">{{name}}</strong>
|
||||
<span class="text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
||||
</span>
|
||||
</label>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning mb-0">{{#if isEdit}}Create at least one role before assigning this role.{{else}}Create at least one role before adding users.{{/if}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="form-text mt-2">{{#if isEdit}}Select one or more roles. Leave them blank to remove all roles.{{else}}Select one or more roles, or leave them blank to create a user without roles.{{/if}}</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="py-3"><div class="alert alert-warning mb-0">{{#if isEdit}}Create at least one role before assigning this role.{{else}}Create at least one role before adding users.{{/if}}</div></div>
|
||||
{{/if}}
|
||||
<div class="form-text border-top py-2">{{#if isEdit}}Select one or more roles. Leave them blank to remove all roles.{{else}}Select one or more roles, or leave them blank to create a user without roles.{{/if}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,6 +163,14 @@
|
||||
</form>
|
||||
|
||||
{{#if isEdit}}
|
||||
{{#if user.canUnlinkTwoFactor}}
|
||||
<form id="unlink-two-factor-form" method="post" action="/settings/users/{{user.id}}/two-factor/unlink" data-confirm-message="Unlink two-step verification? The user will need to sign in again."></form>
|
||||
{{/if}}
|
||||
{{#if user.canUnlinkPasskeys}}
|
||||
{{#each user.passkeys}}
|
||||
<form id="unlink-passkey-form-{{id}}" method="post" action="/settings/users/{{../user.id}}/passkeys/{{id}}/unlink" data-confirm-message="Unlink this passkey? The user will need to sign in again."></form>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'impersonate.allow')}}
|
||||
{{#unless currentUser.isViewingAs}}
|
||||
<form id="view-as-user-form" method="post" action="/settings/users/{{user.id}}/view-as" data-confirm-message="View as {{user.username}}? This preview is read-only and ends after 15 minutes."></form>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Invite user</h2>
|
||||
<p>Send a secure invitation. The account is created when the recipient accepts it.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/settings/users/invite">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Invitation details</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{#if message}}<div class="alert alert-{{messageVariant}}">{{message}}</div>{{/if}}
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label for="invite-email" class="form-label">Email address</label>
|
||||
<input id="invite-email" name="email" type="email" class="form-control" value="{{formValues.email}}" autocomplete="email" maxlength="320" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="invite-name" class="form-label">Display name</label>
|
||||
<input id="invite-name" name="name" type="text" class="form-control" value="{{formValues.name}}" autocomplete="name" maxlength="255" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Invitation actions">
|
||||
<button type="submit" class="btn btn-primary">Send invitation</button>
|
||||
<a class="btn btn-secondary" href="/settings/users">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card card-outline card-secondary admin-form-card">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Roles</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{{#if roles.length}}
|
||||
{{#each roles}}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
|
||||
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}}>
|
||||
<span class="form-check-label pe-4">
|
||||
<strong>{{name}}</strong>
|
||||
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning mb-0">Create a role before sending invitations.</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="form-text mt-2">The recipient will receive these roles when the invitation is accepted.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -13,11 +13,7 @@
|
||||
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search users" aria-label="Search users" data-table-search />
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
{{#if (hasPermission currentUser 'invitations.create')}}
|
||||
<a href="/settings/users/invite" class="btn btn-info btn-sm">Invite user</a><a class="btn btn-primary btn-sm" href="/settings/users/new">Add<span class="table-action-context"> user</span></a>
|
||||
{{/if}}
|
||||
</div>
|
||||
<a class="btn btn-primary btn-sm" href="/settings/users/new">Add<span class="table-action-context"> user</span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
|
||||
@@ -28,36 +28,19 @@
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
<script src="/assets/js/shared/system-status.js"></script>
|
||||
{{else if authShell}}
|
||||
<div id="app-toast-container" class="toast-container app-toast-container position-fixed end-0 p-3">
|
||||
{{#if message}}
|
||||
<div class="toast shadow-lg border-0" id="app-toast" role="status" aria-live="polite" aria-atomic="true" data-bs-autohide="true" data-bs-delay="4000" data-toast-variant="{{#if messageVariant}}{{messageVariant}}{{else}}warning{{/if}}" data-toast-created-at="">
|
||||
<div class="toast-header border-0 text-bg-{{#if messageVariant}}{{messageVariant}}{{else}}warning{{/if}}">
|
||||
{{#if (eq messageVariant 'success')}}
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
{{else if (eq messageVariant 'warning')}}
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{{else if (eq messageVariant 'danger')}}
|
||||
<i class="bi bi-x-circle-fill me-2"></i>
|
||||
{{else if (eq messageVariant 'primary')}}
|
||||
<i class="bi bi-bell-fill me-2"></i>
|
||||
{{else}}
|
||||
<i class="bi bi-info-circle-fill me-2"></i>
|
||||
{{/if}}
|
||||
<strong class="me-auto">Pulse</strong>
|
||||
<small class="toast-time text-nowrap">just now</small>
|
||||
<button type="button" class="btn-close btn-close-white ms-2" data-bs-dismiss="toast" aria-label="Dismiss notification"></button>
|
||||
</div>
|
||||
<div class="toast-body">{{message}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<main class="login-page d-flex align-items-center justify-content-center min-vh-100 p-3">
|
||||
<div class="login-box">
|
||||
{{{body}}}
|
||||
</div>
|
||||
</main>
|
||||
<script src="/assets/js/shared/ui/toast.js"></script>
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
{{#each scripts}}
|
||||
{{#if (isExternalUrl this)}}
|
||||
<script src="{{this}}"></script>
|
||||
{{else}}
|
||||
<script src="/assets/{{this}}"></script>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
<script src="/assets/js/shared/system-status.js"></script>
|
||||
{{else}}
|
||||
<div id="app-toast-container" class="toast-container app-toast-container position-fixed end-0 p-3">
|
||||
@@ -285,10 +268,10 @@
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'invitations.read' 'fonts.read' 'weather-icons.read')}}
|
||||
{{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'fonts.read' 'weather-icons.read')}}
|
||||
<li class="nav-header">SETTINGS</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'users.read' 'rbac.read' 'invitations.read')}}
|
||||
{{#if (anyPermission currentUser 'users.read' 'rbac.read')}}
|
||||
<li class="nav-item has-treeview{{#if userManagementMenuOpen}} menu-open{{/if}}">
|
||||
<a class="nav-link{{#if userManagementMenuOpen}} active{{/if}}" href="#">
|
||||
<i class="nav-icon bi bi-people"></i>
|
||||
@@ -314,14 +297,6 @@
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'invitations.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'users-invitations')}}active{{/if}}" href="/settings/invitations">
|
||||
<i class="nav-icon bi bi-envelope-open"></i>
|
||||
<p>Pending invitations</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</li>
|
||||
{{/if}}
|
||||
@@ -384,7 +359,7 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'settings')}}active{{/if}}" href="/settings/system">
|
||||
<i class="nav-icon bi bi-sliders"></i>
|
||||
<p>System Settings</p>
|
||||
<p>System settings</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
|
||||
Reference in new issue
Block a user