Adds a global admin setting to hide the username/password form on the login page, useful for deployments that rely solely on SSO/OIDC or passkeys. The form defaults to enabled so existing deployments are unaffected. Changes: - Add PASSWORD_LOGIN_ENABLED_KEY constant - Add isPasswordLoginEnabled / setPasswordLoginEnabled to system service (defaults to true when no DB record exists) - Add GET/PUT /api/v1/system/password-login-status endpoints (GET auth-gated for admin use; setting is exposed to the login page via the existing getLoginOptions server function) - Extend getLoginOptions to include passwordLoginEnabled - Conditionally render the password login form in LoginPage - Add "Enable password login" toggle in the admin System Settings tab, next to "Enable new user registrations" - Add corresponding entries to generated API client files (types.gen.ts, sdk.gen.ts, @tanstack/react-query.gen.ts) Closes #941
140 lines
3.9 KiB
TypeScript
140 lines
3.9 KiB
TypeScript
import { getCapabilities } from "../../core/capabilities";
|
|
import { config } from "../../core/config";
|
|
import type { UpdateInfoDto } from "./system.dto";
|
|
import semver from "semver";
|
|
import { cache, cacheKeys } from "../../utils/cache";
|
|
import { logger } from "@zerobyte/core/node";
|
|
import { db } from "../../db/db";
|
|
import { appMetadataTable } from "../../db/schema";
|
|
import { PASSWORD_LOGIN_ENABLED_KEY, REGISTRATION_ENABLED_KEY } from "~/server/core/constants";
|
|
|
|
const CACHE_TTL = 60 * 60;
|
|
|
|
const getSystemInfo = async () => {
|
|
return {
|
|
capabilities: await getCapabilities(),
|
|
};
|
|
};
|
|
|
|
interface GitHubRelease {
|
|
tag_name: string;
|
|
html_url: string;
|
|
published_at: string;
|
|
body: string;
|
|
}
|
|
|
|
const getUpdates = async (): Promise<UpdateInfoDto> => {
|
|
const CACHE_KEY = cacheKeys.system.githubReleases(config.appVersion);
|
|
|
|
const cached = cache.get<UpdateInfoDto>(CACHE_KEY);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
|
|
const response = await fetch("https://api.github.com/repos/nicotsx/zerobyte/releases", {
|
|
signal: controller.signal,
|
|
headers: {
|
|
"User-Agent": "zerobyte-app",
|
|
},
|
|
});
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`GitHub API returned ${response.status}`);
|
|
}
|
|
|
|
const releases = (await response.json()) as GitHubRelease[];
|
|
const currentVersion = config.appVersion;
|
|
|
|
const formattedReleases = releases.map((r) => ({
|
|
version: r.tag_name,
|
|
url: r.html_url,
|
|
publishedAt: r.published_at,
|
|
body: r.body,
|
|
}));
|
|
|
|
const latestRelease = formattedReleases[0];
|
|
const latestVersion = latestRelease?.version ?? currentVersion;
|
|
|
|
const hasUpdate = !!(
|
|
currentVersion !== "dev" &&
|
|
semver.valid(currentVersion) &&
|
|
semver.valid(latestVersion) &&
|
|
semver.gt(latestVersion, currentVersion)
|
|
);
|
|
|
|
const missedReleases =
|
|
currentVersion === "dev" || !semver.valid(currentVersion)
|
|
? []
|
|
: formattedReleases.filter((r) => !!(semver.valid(r.version) && semver.gt(r.version, currentVersion)));
|
|
|
|
const data = { currentVersion, latestVersion, hasUpdate, missedReleases };
|
|
|
|
cache.set(CACHE_KEY, data, CACHE_TTL);
|
|
|
|
return data;
|
|
} catch (error) {
|
|
logger.error("Failed to fetch updates from GitHub:", error);
|
|
return {
|
|
currentVersion: config.appVersion,
|
|
latestVersion: config.appVersion,
|
|
hasUpdate: false,
|
|
missedReleases: [],
|
|
};
|
|
}
|
|
};
|
|
|
|
const isRegistrationEnabled = async () => {
|
|
const result = await db.query.appMetadataTable.findFirst({
|
|
where: { key: REGISTRATION_ENABLED_KEY },
|
|
});
|
|
|
|
return result?.value === "true";
|
|
};
|
|
|
|
const setRegistrationEnabled = async (enabled: boolean) => {
|
|
const now = Date.now();
|
|
|
|
await db
|
|
.insert(appMetadataTable)
|
|
.values({ key: REGISTRATION_ENABLED_KEY, value: JSON.stringify(enabled), createdAt: now, updatedAt: now })
|
|
.onConflictDoUpdate({ target: appMetadataTable.key, set: { value: JSON.stringify(enabled), updatedAt: now } });
|
|
|
|
logger.info(`Registration enabled set to: ${enabled}`);
|
|
};
|
|
|
|
const isPasswordLoginEnabled = async () => {
|
|
const result = await db.query.appMetadataTable.findFirst({
|
|
where: { key: PASSWORD_LOGIN_ENABLED_KEY },
|
|
});
|
|
|
|
// Default to true so existing deployments are unaffected
|
|
return result === undefined || result.value === "true";
|
|
};
|
|
|
|
const setPasswordLoginEnabled = async (enabled: boolean) => {
|
|
const now = Date.now();
|
|
|
|
await db
|
|
.insert(appMetadataTable)
|
|
.values({ key: PASSWORD_LOGIN_ENABLED_KEY, value: JSON.stringify(enabled), createdAt: now, updatedAt: now })
|
|
.onConflictDoUpdate({ target: appMetadataTable.key, set: { value: JSON.stringify(enabled), updatedAt: now } });
|
|
|
|
logger.info(`Password login enabled set to: ${enabled}`);
|
|
};
|
|
|
|
const isDevPanelEnabled = () => config.flags.enableDevPanel;
|
|
|
|
export const systemService = {
|
|
getSystemInfo,
|
|
getUpdates,
|
|
isRegistrationEnabled,
|
|
setRegistrationEnabled,
|
|
isPasswordLoginEnabled,
|
|
setPasswordLoginEnabled,
|
|
isDevPanelEnabled,
|
|
};
|