Files
zerobyte/app/server/modules/system/system.service.ts
Parman Mohammadalizadeh 34e8f89727 feat: add option to disable password login (#949)
* feat: add option to disable password login

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

* fix(login): show errors even if password is disabled

* feat(cli): add command to re-enable password

---------

Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-06-16 22:26:56 +02:00

175 lines
4.7 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_DISABLED_KEY, REGISTRATION_ENABLED_KEY } from "~/server/core/constants";
import type { BackendType } from "@zerobyte/contracts/volumes";
import type { RepositoryBackend } from "@zerobyte/core/restic";
import { serverHasRuntimeFeature } from "~/server/lib/permission-service";
const CACHE_TTL = 60 * 60;
const getSystemInfo = async () => {
const capabilities = await getCapabilities();
const volumeBackends: BackendType[] = ["directory"];
const repositoryBackends: RepositoryBackend[] = ["local", "s3", "r2", "gcs", "azure", "sftp", "rest"];
if (serverHasRuntimeFeature("remoteVolumeBackends")) {
if (capabilities.sysAdmin) {
volumeBackends.push("nfs", "smb", "webdav", "sftp");
}
if (capabilities.sysAdmin && capabilities.rclone) {
volumeBackends.push("rclone");
}
if (capabilities.rclone) {
repositoryBackends.push("rclone");
}
}
return {
runtime: config.runtime,
capabilities: {
rclone: capabilities.rclone,
sysAdmin: capabilities.sysAdmin,
volumeBackends,
repositoryBackends,
},
};
};
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 isPasswordLoginDisabled = async () => {
const result = await db.query.appMetadataTable.findFirst({
where: { key: PASSWORD_LOGIN_DISABLED_KEY },
});
return result?.value === "true";
};
const setPasswordLoginDisabled = async (disabled: boolean) => {
const now = Date.now();
await db
.insert(appMetadataTable)
.values({
key: PASSWORD_LOGIN_DISABLED_KEY,
value: JSON.stringify(disabled),
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: appMetadataTable.key,
set: { value: JSON.stringify(disabled), updatedAt: now },
});
logger.info(`Password login disabled set to: ${disabled}`);
};
const isDevPanelEnabled = () => config.flags.enableDevPanel;
export const systemService = {
getSystemInfo,
getUpdates,
isRegistrationEnabled,
setRegistrationEnabled,
isPasswordLoginDisabled,
setPasswordLoginDisabled,
isDevPanelEnabled,
};