add configurable club login domains in RMS software

This commit is contained in:
2026-04-02 22:06:19 +02:00
parent 1cc2014034
commit 66b08693b9
4 changed files with 278 additions and 20 deletions

View File

@@ -71,6 +71,7 @@ const config = {
autoDisableTxBeforeActivation: String(process.env.AUTO_DISABLE_TX_BEFORE_ACTIVATION || "").trim().toLowerCase(),
stationMaxUsageSec: Number(process.env.STATION_MAX_USAGE_SEC || 3600),
primaryEmailDomain: String(process.env.PRIMARY_EMAIL_DOMAIN || "arcg.at").toLowerCase(),
loginAllowedDomains: parseDomainList(process.env.ALLOWED_LOGIN_DOMAINS || "arcg.at,oevsv.at"),
publicBaseUrl: String(process.env.PUBLIC_BASE_URL || ""),
approverEmails: new Set(
String(process.env.APPROVER_EMAILS || "")
@@ -162,6 +163,7 @@ const runtime = {
logoLightUrl: null,
logoDarkUrl: null
},
loginAllowedDomains: config.loginAllowedDomains,
updatedAt: null
},
pluginState: {
@@ -301,6 +303,9 @@ async function routeRequest(req, res) {
maintenanceMode: Boolean(runtime.systemState.maintenanceMode),
maintenanceMessage: runtime.systemState.maintenanceMessage,
branding: runtime.systemState.branding,
allowedLoginDomains: Array.isArray(runtime.systemState.loginAllowedDomains)
? runtime.systemState.loginAllowedDomains
: config.loginAllowedDomains,
updatedAt: runtime.systemState.updatedAt
});
}
@@ -849,6 +854,31 @@ async function routeRequest(req, res) {
return handleMaintenanceUpdate(res, auth.user, body);
}
if (method === "GET" && url.pathname === "/v1/admin/login-domains") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
return sendJson(res, 200, {
ok: true,
domains: Array.isArray(runtime.systemState.loginAllowedDomains)
? runtime.systemState.loginAllowedDomains
: config.loginAllowedDomains,
updatedAt: runtime.systemState.updatedAt
});
}
if (method === "PUT" && url.pathname === "/v1/admin/login-domains") {
const auth = requireAuth(req, res);
if (!auth) return;
if (!hasRole(auth.user, ["admin"])) {
return sendError(res, 403, "auth.forbidden", "Nur Admin");
}
const body = await readJsonBody(req);
return handleLoginDomainsUpdate(res, auth.user, body);
}
if (method === "PUT" && url.pathname === "/v1/admin/branding/logo") {
const auth = requireAuth(req, res);
if (!auth) return;
@@ -940,6 +970,9 @@ async function handleRequestAccess(req, res, body) {
if (!isValidEmail(email)) {
return sendError(res, 400, "auth.invalid_email", "Bitte gueltige E-Mail angeben");
}
if (!isLoginDomainAllowed(email)) {
return sendError(res, 403, "auth.email.domain_forbidden", "Nur Club-Mailadressen (@arcg.at oder @oevsv.at) sind zum Anmelden moeglich");
}
let user = runtime.users.find((entry) => entry.email === email);
if (runtime.systemState.maintenanceMode) {
@@ -1573,6 +1606,22 @@ async function handleMaintenanceUpdate(res, actor, body) {
});
}
async function handleLoginDomainsUpdate(res, actor, body) {
const domains = normalizeLoginDomainList(body && body.domains);
if (domains.length === 0) {
return sendError(res, 400, "auth.login_domains.invalid", "Mindestens eine gueltige Domain ist erforderlich");
}
runtime.systemState.loginAllowedDomains = domains;
runtime.systemState.updatedAt = new Date().toISOString();
await saveSystemState();
await appendAudit("admin.login_domains.update", actor, { domains });
return sendJson(res, 200, {
ok: true,
domains,
updatedAt: runtime.systemState.updatedAt
});
}
async function handleAdminBrandingLogoUpdate(res, actor, body) {
const theme = typeof (body && body.theme) === "string" ? body.theme.trim().toLowerCase() : "";
if (theme !== "light" && theme !== "dark") {
@@ -1743,6 +1792,7 @@ function parseImageDataUrl(value) {
function normalizeSystemState(state) {
const input = state && typeof state === "object" ? state : {};
const branding = input.branding && typeof input.branding === "object" ? input.branding : {};
const loginAllowedDomains = normalizeLoginDomainList(input.loginAllowedDomains);
return {
maintenanceMode: Boolean(input.maintenanceMode),
maintenanceMessage: typeof input.maintenanceMessage === "string" && input.maintenanceMessage.trim()
@@ -1752,6 +1802,7 @@ function normalizeSystemState(state) {
logoLightUrl: typeof branding.logoLightUrl === "string" && branding.logoLightUrl.trim() ? branding.logoLightUrl : null,
logoDarkUrl: typeof branding.logoDarkUrl === "string" && branding.logoDarkUrl.trim() ? branding.logoDarkUrl : null
},
loginAllowedDomains: loginAllowedDomains.length > 0 ? loginAllowedDomains : config.loginAllowedDomains,
updatedAt: input.updatedAt || null
};
}
@@ -2495,10 +2546,41 @@ function domainForEmail(email) {
return at === -1 ? "" : email.slice(at + 1).toLowerCase();
}
function parseDomainList(value) {
return normalizeLoginDomainList(String(value || ""));
}
function normalizeLoginDomainList(value) {
const list = Array.isArray(value)
? value
: String(value || "")
.split(/[\n,;\s]+/);
const unique = [];
const seen = new Set();
for (const entry of list) {
const domain = String(entry || "").trim().toLowerCase();
if (!domain) continue;
if (!/^[a-z0-9.-]+$/.test(domain)) continue;
if (!domain.includes(".")) continue;
if (seen.has(domain)) continue;
seen.add(domain);
unique.push(domain);
}
return unique;
}
function isPrimaryDomainEmail(email) {
return domainForEmail(email) === config.primaryEmailDomain;
}
function isLoginDomainAllowed(email) {
const allowed = Array.isArray(runtime.systemState && runtime.systemState.loginAllowedDomains)
? runtime.systemState.loginAllowedDomains
: config.loginAllowedDomains;
const domain = domainForEmail(email);
return allowed.includes(domain);
}
function stationUsageDurationMs() {
return Math.max(1, Number(config.stationMaxUsageSec || 3600)) * 1000;
}