Update electron builds, fix backend issues

This commit is contained in:
LukeGus
2025-09-28 16:45:24 -05:00
parent cfa7c26c49
commit d1b4345206
16 changed files with 410 additions and 1811 deletions

View File

@@ -9,7 +9,6 @@ COPY package*.json ./
ENV npm_config_target_platform=linux ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64 ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc ENV npm_config_target_libc=glibc
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN npm ci --force --ignore-scripts && \ RUN npm ci --force --ignore-scripts && \
npm cache clean --force npm cache clean --force
@@ -20,11 +19,7 @@ WORKDIR /app
COPY . . COPY . .
ENV NODE_OPTIONS="--max-old-space-size=4096"
ENV NODE_ENV=production
RUN npm run build RUN npm run build
RUN npm run build:types
# Stage 3: Build backend TypeScript # Stage 3: Build backend TypeScript
FROM deps AS backend-builder FROM deps AS backend-builder
@@ -35,8 +30,6 @@ COPY . .
ENV npm_config_target_platform=linux ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64 ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc ENV npm_config_target_libc=glibc
ENV NODE_OPTIONS="--max-old-space-size=4096"
ENV NODE_ENV=production
RUN npm rebuild better-sqlite3 --force RUN npm rebuild better-sqlite3 --force
@@ -46,14 +39,13 @@ RUN npm run build:backend
FROM node:24-alpine AS production-deps FROM node:24-alpine AS production-deps
WORKDIR /app WORKDIR /app
RUN apk add --no-cache python3 make g++ RUN apk add --no-cache python3 make g++
COPY package*.json ./ COPY package*.json ./
ENV npm_config_target_platform=linux ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64 ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc ENV npm_config_target_libc=glibc
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN npm ci --only=production --ignore-scripts --force && \ RUN npm ci --only=production --ignore-scripts --force && \
npm rebuild better-sqlite3 bcryptjs --force && \ npm rebuild better-sqlite3 bcryptjs --force && \
@@ -90,4 +82,4 @@ EXPOSE ${PORT} 30001 30002 30003 30004 30005
COPY docker/entrypoint.sh /entrypoint.sh COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"] CMD ["/entrypoint.sh"]

View File

@@ -32,11 +32,9 @@ if [ "$ENABLE_SSL" = "true" ]; then
DOMAIN=${SSL_DOMAIN:-localhost} DOMAIN=${SSL_DOMAIN:-localhost}
# Check if certificates already exist and are valid
if [ -f "/app/data/ssl/termix.crt" ] && [ -f "/app/data/ssl/termix.key" ]; then if [ -f "/app/data/ssl/termix.crt" ] && [ -f "/app/data/ssl/termix.key" ]; then
echo "SSL certificates found, checking validity..." echo "SSL certificates found, checking validity..."
# Check if certificate is still valid (not expired within 30 days)
if openssl x509 -in /app/data/ssl/termix.crt -checkend 2592000 -noout >/dev/null 2>&1; then if openssl x509 -in /app/data/ssl/termix.crt -checkend 2592000 -noout >/dev/null 2>&1; then
echo "SSL certificates are valid and will be reused for domain: $DOMAIN" echo "SSL certificates are valid and will be reused for domain: $DOMAIN"
else else
@@ -47,7 +45,6 @@ if [ "$ENABLE_SSL" = "true" ]; then
echo "SSL certificates not found, will generate new ones..." echo "SSL certificates not found, will generate new ones..."
fi fi
# Generate certificates only if they don't exist or are invalid
if [ ! -f "/app/data/ssl/termix.crt" ] || [ ! -f "/app/data/ssl/termix.key" ]; then if [ ! -f "/app/data/ssl/termix.crt" ] || [ ! -f "/app/data/ssl/termix.key" ]; then
echo "Generating SSL certificates for domain: $DOMAIN" echo "Generating SSL certificates for domain: $DOMAIN"
@@ -101,6 +98,18 @@ echo "Starting backend services..."
cd /app cd /app
export NODE_ENV=production export NODE_ENV=production
if [ -f "package.json" ]; then
VERSION=$(grep '"version"' package.json | sed 's/.*"version": *"\([^"]*\)".*/\1/')
if [ -n "$VERSION" ]; then
export VERSION
echo "Detected version: $VERSION"
else
echo "Warning: Could not extract version from package.json"
fi
else
echo "Warning: package.json not found"
fi
if command -v su-exec > /dev/null 2>&1; then if command -v su-exec > /dev/null 2>&1; then
su-exec node node dist/backend/backend/starter.js su-exec node node dist/backend/backend/starter.js
else else

View File

@@ -126,26 +126,31 @@ async function fetchGitHubAPI(endpoint, cacheKey) {
const isHttps = urlObj.protocol === "https:"; const isHttps = urlObj.protocol === "https:";
const client = isHttps ? https : http; const client = isHttps ? https : http;
const req = client.request( const requestOptions = {
url, method: options.method || "GET",
{ headers: options.headers || {},
method: options.method || "GET", timeout: options.timeout || 10000,
headers: options.headers || {}, };
timeout: options.timeout || 10000,
}, if (isHttps) {
(res) => { requestOptions.rejectUnauthorized = false;
let data = ""; requestOptions.agent = new https.Agent({
res.on("data", (chunk) => (data += chunk)); rejectUnauthorized: false,
res.on("end", () => { });
resolve({ }
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode, const req = client.request(url, requestOptions, (res) => {
text: () => Promise.resolve(data), let data = "";
json: () => Promise.resolve(JSON.parse(data)), res.on("data", (chunk) => (data += chunk));
}); res.on("end", () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
text: () => Promise.resolve(data),
json: () => Promise.resolve(JSON.parse(data)),
}); });
}, });
); });
req.on("error", reject); req.on("error", reject);
req.on("timeout", () => { req.on("timeout", () => {
@@ -295,26 +300,31 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
const isHttps = urlObj.protocol === "https:"; const isHttps = urlObj.protocol === "https:";
const client = isHttps ? https : http; const client = isHttps ? https : http;
const req = client.request( const requestOptions = {
url, method: options.method || "GET",
{ headers: options.headers || {},
method: options.method || "GET", timeout: options.timeout || 5000,
headers: options.headers || {}, };
timeout: options.timeout || 5000,
}, if (isHttps) {
(res) => { requestOptions.rejectUnauthorized = false;
let data = ""; requestOptions.agent = new https.Agent({
res.on("data", (chunk) => (data += chunk)); rejectUnauthorized: false,
res.on("end", () => { });
resolve({ }
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode, const req = client.request(url, requestOptions, (res) => {
text: () => Promise.resolve(data), let data = "";
json: () => Promise.resolve(JSON.parse(data)), res.on("data", (chunk) => (data += chunk));
}); res.on("end", () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
text: () => Promise.resolve(data),
json: () => Promise.resolve(JSON.parse(data)),
}); });
}, });
); });
req.on("error", reject); req.on("error", reject);
req.on("timeout", () => { req.on("timeout", () => {

1515
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,7 @@
"clean": "npx prettier . --write", "clean": "npx prettier . --write",
"dev": "vite", "dev": "vite",
"dev:https": "cross-env VITE_HTTPS=true vite", "dev:https": "cross-env VITE_HTTPS=true vite",
"build": "vite build", "build": "vite build && tsc -p tsconfig.node.json",
"build:types": "tsc -p tsconfig.node.json",
"build:full": "npm run build && npm run build:types",
"build:backend": "tsc -p tsconfig.node.json", "build:backend": "tsc -p tsconfig.node.json",
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js", "dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
"start": "npm run build:backend && node ./dist/backend/backend/starter.js", "start": "npm run build:backend && node ./dist/backend/backend/starter.js",
@@ -132,4 +130,4 @@
"typescript-eslint": "^8.40.0", "typescript-eslint": "^8.40.0",
"vite": "^7.1.5" "vite": "^7.1.5"
} }
} }

View File

@@ -218,14 +218,46 @@ app.get("/version", authenticateJWT, async (req, res) => {
let localVersion = process.env.VERSION; let localVersion = process.env.VERSION;
if (!localVersion) { if (!localVersion) {
try { const versionSources = [
const packagePath = path.resolve(process.cwd(), "package.json"); () => {
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); try {
localVersion = packageJson.version; const packagePath = path.resolve(process.cwd(), "package.json");
} catch (error) { const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
databaseLogger.error("Failed to read version from package.json", error, { return packageJson.version;
operation: "version_check", } catch {
}); return null;
}
},
() => {
try {
const packagePath = path.resolve("/app", "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch {
return null;
}
},
() => {
try {
const packagePath = path.resolve(__dirname, "../../../package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
} catch {
return null;
}
}
];
for (const getVersion of versionSources) {
try {
const foundVersion = getVersion();
if (foundVersion && foundVersion !== "unknown") {
localVersion = foundVersion;
break;
}
} catch (error) {
continue;
}
} }
} }

View File

@@ -126,15 +126,18 @@ async function initializeCompleteDatabase(): Promise<void> {
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0, is_admin INTEGER NOT NULL DEFAULT 0,
is_oidc INTEGER NOT NULL DEFAULT 0, is_oidc INTEGER NOT NULL DEFAULT 0,
client_id TEXT NOT NULL, oidc_identifier TEXT,
client_secret TEXT NOT NULL, client_id TEXT,
issuer_url TEXT NOT NULL, client_secret TEXT,
authorization_url TEXT NOT NULL, issuer_url TEXT,
token_url TEXT NOT NULL, authorization_url TEXT,
redirect_uri TEXT, token_url TEXT,
identifier_path TEXT NOT NULL, identifier_path TEXT,
name_path TEXT NOT NULL, name_path TEXT,
scopes TEXT NOT NULL scopes TEXT DEFAULT 'openid email profile',
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
totp_backup_codes TEXT
); );
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (

View File

@@ -1,5 +1,6 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import { readFileSync } from "fs";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { AutoSSLSetup } from "./utils/auto-ssl-setup.js"; import { AutoSSLSetup } from "./utils/auto-ssl-setup.js";
@@ -23,18 +24,52 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
} catch {} } catch {}
let version = "unknown"; let version = "unknown";
try {
const __filename = fileURLToPath(import.meta.url); const versionSources = [
const packageJsonPath = path.join( () => process.env.VERSION,
path.dirname(__filename), () => {
"../../../package.json", try {
); const packageJsonPath = path.join(process.cwd(), "package.json");
const packageJson = JSON.parse( const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
await fs.readFile(packageJsonPath, "utf-8"), return packageJson.version;
); } catch {
version = packageJson.version || "unknown"; return null;
} catch (error) { }
version = process.env.VERSION || "unknown"; },
() => {
try {
const __filename = fileURLToPath(import.meta.url);
const packageJsonPath = path.join(
path.dirname(__filename),
"../../../package.json",
);
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
return packageJson.version;
} catch {
return null;
}
},
() => {
try {
const packageJsonPath = path.join("/app", "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
return packageJson.version;
} catch {
return null;
}
}
];
for (const getVersion of versionSources) {
try {
const foundVersion = getVersion();
if (foundVersion && foundVersion !== "unknown") {
version = foundVersion;
break;
}
} catch (error) {
continue;
}
} }
versionLogger.info(`Termix Backend starting - Version: ${version}`, { versionLogger.info(`Termix Backend starting - Version: ${version}`, {
operation: "startup", operation: "startup",
@@ -50,79 +85,6 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
const dbModule = await import("./database/db/index.js"); const dbModule = await import("./database/db/index.js");
await dbModule.initializeDatabase(); await dbModule.initializeDatabase();
if (process.env.NODE_ENV === "production") {
const securityIssues: string[] = [];
if (!process.env.JWT_SECRET) {
systemLogger.warn(
"JWT_SECRET not set - using auto-generated keys (consider setting for production)",
{
operation: "security_warning",
note: "Auto-generated keys are secure but not persistent across deployments",
},
);
} else if (process.env.JWT_SECRET.length < 64) {
securityIssues.push(
"JWT_SECRET should be at least 64 characters in production",
);
}
if (!process.env.DATABASE_KEY) {
systemLogger.warn(
"DATABASE_KEY not set - using auto-generated keys (consider setting for production)",
{
operation: "security_warning",
note: "Auto-generated keys are secure but not persistent across deployments",
},
);
} else if (process.env.DATABASE_KEY.length < 64) {
securityIssues.push(
"DATABASE_KEY should be at least 64 characters in production",
);
}
if (!process.env.INTERNAL_AUTH_TOKEN) {
systemLogger.warn(
"INTERNAL_AUTH_TOKEN not set - using auto-generated token (consider setting for production)",
{
operation: "security_warning",
note: "Auto-generated tokens are secure but not persistent across deployments",
},
);
} else if (process.env.INTERNAL_AUTH_TOKEN.length < 32) {
securityIssues.push(
"INTERNAL_AUTH_TOKEN should be at least 32 characters in production",
);
}
if (process.env.DB_FILE_ENCRYPTION === "false") {
securityIssues.push(
"Database file encryption should be enabled in production",
);
}
systemLogger.warn(
"Production deployment detected - ensure CORS is properly configured",
{
operation: "security_checks",
warning: "Verify frontend domain whitelist",
},
);
if (securityIssues.length > 0) {
systemLogger.error("SECURITY ISSUES DETECTED IN PRODUCTION:", {
operation: "security_checks_failed",
issues: securityIssues,
});
for (const issue of securityIssues) {
systemLogger.error(`- ${issue}`, { operation: "security_issue" });
}
systemLogger.error("Fix these issues before running in production!", {
operation: "security_checks_failed",
});
process.exit(1);
}
}
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
await authManager.initialize(); await authManager.initialize();

View File

@@ -29,28 +29,17 @@ export class AutoSSLSetup {
try { try {
if (await this.isSSLConfigured()) { if (await this.isSSLConfigured()) {
await this.logCertificateInfo();
await this.setupEnvironmentVariables(); await this.setupEnvironmentVariables();
return; return;
} }
// In Docker, certificates might be generated by entrypoint script
// Check if they exist but weren't detected by isSSLConfigured
try { try {
await fs.access(this.CERT_FILE); await fs.access(this.CERT_FILE);
await fs.access(this.KEY_FILE); await fs.access(this.KEY_FILE);
systemLogger.info("SSL certificates found from entrypoint script", {
operation: "ssl_cert_found_entrypoint",
cert_path: this.CERT_FILE,
key_path: this.KEY_FILE,
});
await this.logCertificateInfo();
await this.setupEnvironmentVariables(); await this.setupEnvironmentVariables();
return; return;
} catch { } catch {
// Certificates don't exist, generate them
await this.generateSSLCertificates(); await this.generateSSLCertificates();
await this.setupEnvironmentVariables(); await this.setupEnvironmentVariables();
} }
@@ -169,8 +158,6 @@ IP.2 = ::1
key_path: this.KEY_FILE, key_path: this.KEY_FILE,
valid_days: 365, valid_days: 365,
}); });
await this.logCertificateInfo();
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`SSL certificate generation failed: ${error instanceof Error ? error.message : "Unknown error"}`, `SSL certificate generation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
@@ -178,49 +165,6 @@ IP.2 = ::1
} }
} }
private static async logCertificateInfo(): Promise<void> {
try {
const subject = execSync(
`openssl x509 -in "${this.CERT_FILE}" -noout -subject`,
{ stdio: "pipe" },
)
.toString()
.trim();
const issuer = execSync(
`openssl x509 -in "${this.CERT_FILE}" -noout -issuer`,
{ stdio: "pipe" },
)
.toString()
.trim();
const notAfter = execSync(
`openssl x509 -in "${this.CERT_FILE}" -noout -enddate`,
{ stdio: "pipe" },
)
.toString()
.trim();
const notBefore = execSync(
`openssl x509 -in "${this.CERT_FILE}" -noout -startdate`,
{ stdio: "pipe" },
)
.toString()
.trim();
systemLogger.info("SSL Certificate Information:", {
operation: "ssl_cert_info",
subject: subject.replace("subject=", ""),
issuer: issuer.replace("issuer=", ""),
valid_from: notBefore.replace("notBefore=", ""),
valid_until: notAfter.replace("notAfter=", ""),
note: "Certificate will auto-renew 30 days before expiration",
});
} catch (error) {
systemLogger.warn("Could not retrieve certificate information", {
operation: "ssl_cert_info_error",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
private static async setupEnvironmentVariables(): Promise<void> { private static async setupEnvironmentVariables(): Promise<void> {
const certPath = this.CERT_FILE; const certPath = this.CERT_FILE;
const keyPath = this.KEY_FILE; const keyPath = this.KEY_FILE;

View File

@@ -1,23 +1,47 @@
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner"; import { Toaster as Sonner, type ToasterProps, toast } from "sonner";
import { useRef } from "react";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme(); const { theme = "system" } = useTheme();
const lastToastRef = useRef<{ text: string; timestamp: number } | null>(null);
return ( const originalToast = toast;
<Sonner
theme={theme as ToasterProps["theme"]} const rateLimitedToast = (message: string, options?: any) => {
className="toaster group" const now = Date.now();
style={ const lastToast = lastToastRef.current;
{
"--normal-bg": "var(--popover)", if (lastToast && lastToast.text === message && (now - lastToast.timestamp) < 1000) {
"--normal-text": "var(--popover-foreground)", return;
"--normal-border": "var(--border)", }
} as React.CSSProperties
} lastToastRef.current = { text: message, timestamp: now };
{...props} return originalToast(message, options);
/> };
);
Object.assign(toast, {
success: (message: string, options?: any) => rateLimitedToast(message, { ...options, type: 'success' }),
error: (message: string, options?: any) => rateLimitedToast(message, { ...options, type: 'error' }),
warning: (message: string, options?: any) => rateLimitedToast(message, { ...options, type: 'warning' }),
info: (message: string, options?: any) => rateLimitedToast(message, { ...options, type: 'info' }),
message: rateLimitedToast,
});
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
}; };
export { Toaster }; export { Toaster };

View File

@@ -0,0 +1,180 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button.tsx";
import { VersionAlert } from "@/components/ui/version-alert.tsx";
import { RefreshCw, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { checkElectronUpdate, isElectron } from "@/ui/main-axios.ts";
interface VersionCheckModalProps {
onDismiss: () => void;
onContinue: () => void;
}
export function VersionCheckModal({ onDismiss, onContinue }: VersionCheckModalProps) {
const { t } = useTranslation();
const [versionInfo, setVersionInfo] = useState<any>(null);
const [versionChecking, setVersionChecking] = useState(false);
const [versionDismissed, setVersionDismissed] = useState(false);
useEffect(() => {
if (isElectron()) {
checkForUpdates();
} else {
onContinue();
}
}, []);
const checkForUpdates = async () => {
setVersionChecking(true);
try {
const updateInfo = await checkElectronUpdate();
setVersionInfo(updateInfo);
} catch (error) {
console.error("Failed to check for updates:", error);
setVersionInfo({ success: false, error: "Check failed" });
} finally {
setVersionChecking(false);
}
};
const handleVersionDismiss = () => {
setVersionDismissed(true);
};
const handleDownloadUpdate = () => {
if (versionInfo?.latest_release?.html_url) {
window.open(versionInfo.latest_release.html_url, "_blank");
}
};
const handleContinue = () => {
onContinue();
};
if (!isElectron()) {
return null;
}
if (versionChecking && !versionInfo) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-dark-bg border border-dark-border rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center justify-center mb-4">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
<p className="text-center text-muted-foreground">
{t("versionCheck.checkingUpdates")}
</p>
</div>
</div>
);
}
if (!versionInfo || versionInfo.status === "up_to_date" || versionDismissed) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-dark-bg border border-dark-border rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">
{t("versionCheck.checkUpdates")}
</h2>
<Button
variant="ghost"
size="sm"
onClick={onDismiss}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
{versionInfo && !versionDismissed && (
<div className="mb-4">
<VersionAlert
updateInfo={versionInfo}
onDismiss={handleVersionDismiss}
onDownload={handleDownloadUpdate}
showDismiss={true}
/>
</div>
)}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={checkForUpdates}
disabled={versionChecking}
className="flex-1"
>
{versionChecking ? (
<div className="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
{t("versionCheck.refresh")}
</Button>
<Button
onClick={handleContinue}
className="flex-1"
>
{t("common.continue")}
</Button>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-dark-bg border border-dark-border rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">
{t("versionCheck.updateRequired")}
</h2>
<Button
variant="ghost"
size="sm"
onClick={onDismiss}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="mb-4">
<VersionAlert
updateInfo={versionInfo}
onDismiss={handleVersionDismiss}
onDownload={handleDownloadUpdate}
showDismiss={true}
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={checkForUpdates}
disabled={versionChecking}
className="flex-1"
>
{versionChecking ? (
<div className="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
{t("versionCheck.refresh")}
</Button>
<Button
onClick={handleContinue}
className="flex-1"
>
{t("common.continue")}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -226,7 +226,10 @@
"downloadUpdate": "Download Update", "downloadUpdate": "Download Update",
"dismiss": "Dismiss", "dismiss": "Dismiss",
"checking": "Checking for updates...", "checking": "Checking for updates...",
"checkUpdates": "Check for Updates" "checkUpdates": "Check for Updates",
"checkingUpdates": "Checking for updates...",
"refresh": "Refresh",
"updateRequired": "Update Required"
}, },
"common": { "common": {
"close": "Close", "close": "Close",
@@ -1207,7 +1210,7 @@
"databaseConnected": "Database connected successfully", "databaseConnected": "Database connected successfully",
"databaseConnectionFailed": "Failed to connect to the database server", "databaseConnectionFailed": "Failed to connect to the database server",
"checkServerConnection": "Please check your server connection and try again", "checkServerConnection": "Please check your server connection and try again",
"resetCodeSent": "Reset code sent to your email", "resetCodeSent": "Reset code sent to Docker logs",
"codeVerified": "Code verified successfully", "codeVerified": "Code verified successfully",
"passwordResetSuccess": "Password reset successfully", "passwordResetSuccess": "Password reset successfully",
"loginSuccess": "Login successful", "loginSuccess": "Login successful",

View File

@@ -224,7 +224,10 @@
"downloadUpdate": "下载更新", "downloadUpdate": "下载更新",
"dismiss": "忽略", "dismiss": "忽略",
"checking": "正在检查更新...", "checking": "正在检查更新...",
"checkUpdates": "检查更新" "checkUpdates": "检查更新",
"checkingUpdates": "正在检查更新...",
"refresh": "刷新",
"updateRequired": "需要更新"
}, },
"common": { "common": {
"close": "关闭", "close": "关闭",
@@ -1184,7 +1187,7 @@
"databaseConnected": "数据库连接成功", "databaseConnected": "数据库连接成功",
"databaseConnectionFailed": "无法连接到数据库服务器", "databaseConnectionFailed": "无法连接到数据库服务器",
"checkServerConnection": "请检查您的服务器连接并重试", "checkServerConnection": "请检查您的服务器连接并重试",
"resetCodeSent": "重置代码已发送到您的邮箱", "resetCodeSent": "重置代码已发送到 Docker 日志",
"codeVerified": "代码验证成功", "codeVerified": "代码验证成功",
"passwordResetSuccess": "密码重置成功", "passwordResetSuccess": "密码重置成功",
"loginSuccess": "登录成功", "loginSuccess": "登录成功",

View File

@@ -11,6 +11,7 @@ import { TopNavbar } from "@/ui/Desktop/Navigation/TopNavbar.tsx";
import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx"; import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx"; import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner.tsx"; import { Toaster } from "@/components/ui/sonner.tsx";
import { VersionCheckModal } from "@/components/ui/version-check-modal.tsx";
import { getUserInfo, getCookie } from "@/ui/main-axios.ts"; import { getUserInfo, getCookie } from "@/ui/main-axios.ts";
function AppContent() { function AppContent() {
@@ -22,6 +23,7 @@ function AppContent() {
const [username, setUsername] = useState<string | null>(null); const [username, setUsername] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [authLoading, setAuthLoading] = useState(true); const [authLoading, setAuthLoading] = useState(true);
const [showVersionCheck, setShowVersionCheck] = useState(true);
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true); const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
const { currentTab, tabs } = useTabs(); const { currentTab, tabs } = useTabs();
@@ -94,6 +96,13 @@ function AppContent() {
return ( return (
<div> <div>
{showVersionCheck && (
<VersionCheckModal
onDismiss={() => setShowVersionCheck(false)}
onContinue={() => setShowVersionCheck(false)}
/>
)}
{!isAuthenticated && !authLoading && ( {!isAuthenticated && !authLoading && (
<div> <div>
<div <div

View File

@@ -3,16 +3,14 @@ import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import { Label } from "@/components/ui/label.tsx"; import { Label } from "@/components/ui/label.tsx";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx"; import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
import { VersionAlert } from "@/components/ui/version-alert.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
getServerConfig, getServerConfig,
saveServerConfig, saveServerConfig,
testServerConnection, testServerConnection,
checkElectronUpdate,
type ServerConfig, type ServerConfig,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { CheckCircle, XCircle, Server, Wifi, RefreshCw } from "lucide-react"; import { CheckCircle, XCircle, Server, Wifi } from "lucide-react";
interface ServerConfigProps { interface ServerConfigProps {
onServerConfigured: (serverUrl: string) => void; onServerConfigured: (serverUrl: string) => void;
@@ -33,13 +31,9 @@ export function ServerConfig({
const [connectionStatus, setConnectionStatus] = useState< const [connectionStatus, setConnectionStatus] = useState<
"unknown" | "success" | "error" "unknown" | "success" | "error"
>("unknown"); >("unknown");
const [versionInfo, setVersionInfo] = useState<any>(null);
const [versionChecking, setVersionChecking] = useState(false);
const [versionDismissed, setVersionDismissed] = useState(false);
useEffect(() => { useEffect(() => {
loadServerConfig(); loadServerConfig();
checkForUpdates();
}, []); }, []);
const loadServerConfig = async () => { const loadServerConfig = async () => {
@@ -52,29 +46,6 @@ export function ServerConfig({
} catch (error) {} } catch (error) {}
}; };
const checkForUpdates = async () => {
setVersionChecking(true);
try {
const updateInfo = await checkElectronUpdate();
setVersionInfo(updateInfo);
} catch (error) {
console.error("Failed to check for updates:", error);
setVersionInfo({ success: false, error: "Check failed" });
} finally {
setVersionChecking(false);
}
};
const handleVersionDismiss = () => {
setVersionDismissed(true);
};
const handleDownloadUpdate = () => {
if (versionInfo?.latest_release?.html_url) {
window.open(versionInfo.latest_release.html_url, "_blank");
}
};
const handleTestConnection = async () => { const handleTestConnection = async () => {
if (!serverUrl.trim()) { if (!serverUrl.trim()) {
setError(t("serverConfig.enterServerUrl")); setError(t("serverConfig.enterServerUrl"));
@@ -224,36 +195,6 @@ export function ServerConfig({
</Alert> </Alert>
)} )}
{/* Version Check Section */}
{versionInfo && !versionDismissed && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">
{t("versionCheck.checkUpdates")}
</h3>
<Button
variant="ghost"
size="sm"
onClick={checkForUpdates}
disabled={versionChecking}
className="h-6 px-2"
>
{versionChecking ? (
<div className="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
</Button>
</div>
<VersionAlert
updateInfo={versionInfo}
onDismiss={handleVersionDismiss}
onDownload={handleDownloadUpdate}
showDismiss={true}
/>
</div>
)}
<div className="flex space-x-2"> <div className="flex space-x-2">
{onCancel && !isFirstTime && ( {onCancel && !isFirstTime && (
<Button <Button

View File

@@ -526,8 +526,12 @@ function handleApiError(error: unknown, operation: string): never {
`Auth failed: ${method} ${url} - ${message}`, `Auth failed: ${method} ${url} - ${message}`,
errorContext, errorContext,
); );
const isLoginEndpoint = url?.includes('/users/login');
const errorMessage = isLoginEndpoint ? message : "Authentication required. Please log in again.";
throw new ApiError( throw new ApiError(
"Authentication required. Please log in again.", errorMessage,
401, 401,
"AUTH_REQUIRED", "AUTH_REQUIRED",
); );