finish readme and prepare release
22
README.md
@@ -9,6 +9,7 @@
|
||||
- [Using Docker (Recommended)](#using-docker-recommended)
|
||||
- [API](#api)
|
||||
- [Single Sign-On (SSO) with OIDC](#single-sign-on-sso-with-oidc)
|
||||
- [Localization](#localization)
|
||||
- [Local Development](#local-development)
|
||||
- [Environment Variables](howto/ENV_VARIABLES.md)
|
||||
- [Authentication](#authentication)
|
||||
@@ -68,8 +69,8 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<img width="500px" src="screenshots/jobs-view.png">
|
||||
<img width="500px" src="screenshots/scripts-view.png" />
|
||||
<img width="500px" src="screenshots/home.png">
|
||||
<img width="500px" src="screenshots/live-running.png" />
|
||||
</div>
|
||||
|
||||
<a id="quick-start"></a>
|
||||
@@ -108,7 +109,7 @@ services:
|
||||
init: true
|
||||
```
|
||||
|
||||
**📖 For all available configuration options, see [`howto/DOCKER.md`](howto/DOCKER.md)**
|
||||
📖 **For all available configuration options, see [`howto/DOCKER.md`](howto/DOCKER.md)**
|
||||
|
||||
<a id="api"></a>
|
||||
|
||||
@@ -126,6 +127,14 @@ services:
|
||||
|
||||
📖 **For the complete SSO documentation, see [howto/SSO.md](howto/SSO.md)**
|
||||
|
||||
<a id="localization"></a>
|
||||
|
||||
## Localization
|
||||
|
||||
`cr*nmaster` officially support [some languages](app/_transations) and allows you to create your custom translations locally on your own machine.
|
||||
|
||||
📖 **For the complete Translations documentation, see [howto/TRANSLATIONS.md](howto/TRANSLATIONS.md)**
|
||||
|
||||
### ARM64 Support
|
||||
|
||||
The application supports both AMD64 and ARM64 architectures:
|
||||
@@ -281,7 +290,7 @@ curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
https://your-domain.com/api/cronjobs
|
||||
```
|
||||
|
||||
For complete API documentation with examples, see **[README_API.md](README_API.md)**
|
||||
For complete API documentation with examples, see **[howto/API.md](howto/API.md)**
|
||||
|
||||
### Available Endpoints
|
||||
|
||||
@@ -484,10 +493,9 @@ The application uses standard cron format: `* * * * *`
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
2. Create a feature branch from the `develop` branch
|
||||
3. Make your changes
|
||||
4. Add tests if applicable
|
||||
5. Submit a pull request
|
||||
4. Submit a pull request to the `develop` branch
|
||||
|
||||
## Community shouts
|
||||
|
||||
|
||||
@@ -301,7 +301,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
onNewTaskClick={() => setIsNewCronModalOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[55vh] overflow-y-auto">
|
||||
<div className="space-y-3 max-h-[55vh] overflow-y-auto dropdown-overflow-fix">
|
||||
{loadedSettings ? (
|
||||
filteredJobs.map((job) =>
|
||||
minimalMode ? (
|
||||
|
||||
@@ -2,22 +2,35 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
|
||||
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/app/_components/GlobalComponents/Cards/Card";
|
||||
import { Lock, Eye, EyeOff, Shield } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/app/_components/GlobalComponents/Cards/Card";
|
||||
import { Lock, Eye, EyeOff, Shield, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface LoginFormProps {
|
||||
hasPassword?: boolean;
|
||||
hasOIDC?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormProps) => {
|
||||
export const LoginForm = ({
|
||||
hasPassword = false,
|
||||
hasOIDC = false,
|
||||
version,
|
||||
}: LoginFormProps) => {
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -38,10 +51,10 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
if (result.success) {
|
||||
router.push("/");
|
||||
} else {
|
||||
setError(result.message || "Login failed");
|
||||
setError(result.message || t("login.loginFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
setError("An error occurred. Please try again.");
|
||||
setError(t("login.genericError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -58,17 +71,31 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
|
||||
<Lock className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Welcome to Cr*nMaster</CardTitle>
|
||||
<CardTitle>{t("login.welcomeTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{hasPassword && hasOIDC
|
||||
? "Sign in with password or SSO"
|
||||
? t("login.signInWithPasswordOrSSO")
|
||||
: hasOIDC
|
||||
? "Sign in with SSO"
|
||||
: "Enter your password to continue"}
|
||||
? t("login.signInWithSSO")
|
||||
: t("login.enterPasswordToContinue")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{!hasPassword && !hasOIDC && (
|
||||
<div className="mb-4 p-3 bg-amber-500/10 border border-amber-500/20 rounded-md">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-amber-700 dark:text-amber-400">
|
||||
<div className="font-medium">
|
||||
{t("login.authenticationNotConfigured")}
|
||||
</div>
|
||||
<div className="mt-1">{t("login.noAuthMethodsEnabled")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{hasPassword && (
|
||||
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
||||
@@ -77,7 +104,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
placeholder={t("login.enterPassword")}
|
||||
className="pr-10"
|
||||
required
|
||||
disabled={isLoading}
|
||||
@@ -101,7 +128,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
className="w-full"
|
||||
disabled={isLoading || !password.trim()}
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign In"}
|
||||
{isLoading ? t("login.signingIn") : t("login.signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
@@ -113,7 +140,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
{t("login.orContinueWith")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +155,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
{isLoading ? "Redirecting..." : "Sign in with SSO"}
|
||||
{isLoading ? t("login.redirecting") : t("login.signInWithSSO")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -138,6 +165,14 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{version && (
|
||||
<div className="mt-6 pt-4 border-t border-border/50">
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
Cr*nMaster {t("common.version", { version })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -5,39 +5,38 @@ import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
|
||||
import { LogOut } from "lucide-react";
|
||||
|
||||
|
||||
export const LogoutButton = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
const handleLogout = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
if (response.ok) {
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="h-[1.2rem] w-[1.2rem]" />
|
||||
<span className="sr-only">Logout</span>
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="h-[1.2rem] w-[1.2rem]" />
|
||||
<span className="sr-only">Logout</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ export const FiltersModal = ({
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 min-h-[200px]">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
{t("cronjobs.filterByUser")}
|
||||
|
||||
@@ -110,7 +110,7 @@ export const ScriptModal = ({
|
||||
<Code className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-medium text-foreground">Snippets</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="flex-1 overflow-y-auto min-h-0 !pr-0">
|
||||
<BashSnippetHelper onInsertSnippet={handleInsertSnippet} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,4 +150,4 @@ export const ScriptModal = ({
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,7 +34,9 @@ const categoryIcons = {
|
||||
"Custom Scripts": Code,
|
||||
};
|
||||
|
||||
export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) => {
|
||||
export const BashSnippetHelper = ({
|
||||
onInsertSnippet,
|
||||
}: BashSnippetHelperProps) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
@@ -161,7 +163,7 @@ export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) =
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 overflow-y-auto custom-scrollbar">
|
||||
<div className="space-y-2 overflow-y-auto !pr-0 custom-scrollbar">
|
||||
{filteredSnippets.map((snippet) => {
|
||||
const Icon =
|
||||
categoryIcons[snippet.category as keyof typeof categoryIcons] ||
|
||||
@@ -243,4 +245,4 @@ export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) =
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
|
||||
export const WrapperScriptWarning = () => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem("wrapper-warning-dismissed");
|
||||
if (dismissed === "true") {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
checkWrapperScriptModification();
|
||||
}, []);
|
||||
|
||||
const checkWrapperScriptModification = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/system/wrapper-check");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setIsVisible(data.modified);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check wrapper script:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissWarning = () => {
|
||||
setIsVisible(false);
|
||||
localStorage.setItem("wrapper-warning-dismissed", "true");
|
||||
};
|
||||
|
||||
if (isLoading || !isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-400">
|
||||
{t("warnings.wrapperScriptModified")}
|
||||
</h3>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-500 mt-1">
|
||||
{t("warnings.wrapperScriptModifiedDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={dismissWarning}
|
||||
className="text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-300 transition-colors ml-4"
|
||||
aria-label="Dismiss warning"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -72,8 +72,9 @@ export const UserSwitcher = ({
|
||||
onUserChange(user);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${selectedUser === user ? "bg-accent text-accent-foreground" : ""
|
||||
}`}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${
|
||||
selectedUser === user ? "bg-accent text-accent-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{user}
|
||||
</button>
|
||||
@@ -82,4 +83,4 @@ export const UserSwitcher = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState, useRef, useEffect, ReactNode } from "react";
|
||||
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
|
||||
const DROPDOWN_HEIGHT = 200; // Approximate max height of dropdown
|
||||
|
||||
interface DropdownMenuItem {
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
@@ -28,9 +30,21 @@ export const DropdownMenu = ({
|
||||
onOpenChange,
|
||||
}: DropdownMenuProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [positionAbove, setPositionAbove] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open && triggerRef.current) {
|
||||
// Calculate if dropdown should be positioned above or below
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
|
||||
// Position above if there's not enough space below
|
||||
setPositionAbove(spaceBelow < DROPDOWN_HEIGHT && spaceAbove > spaceBelow);
|
||||
}
|
||||
setIsOpen(open);
|
||||
onOpenChange?.(open);
|
||||
};
|
||||
@@ -72,6 +86,7 @@ export const DropdownMenu = ({
|
||||
return (
|
||||
<div className="relative inline-block" ref={dropdownRef}>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenChange(!isOpen)}
|
||||
@@ -84,7 +99,11 @@ export const DropdownMenu = ({
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-56 rounded-lg border border-border/50 bg-background shadow-lg z-50 overflow-hidden">
|
||||
<div
|
||||
className={`absolute right-0 w-56 rounded-lg border border-border/50 bg-background shadow-lg z-[9999] overflow-hidden ${
|
||||
positionAbove ? "bottom-full mb-2" : "top-full mt-2"
|
||||
}`}
|
||||
>
|
||||
<div className="py-1">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
@@ -99,7 +118,9 @@ export const DropdownMenu = ({
|
||||
: "text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
|
||||
{item.icon && (
|
||||
<span className="flex-shrink-0">{item.icon}</span>
|
||||
)}
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface ModalProps {
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
showCloseButton?: boolean;
|
||||
preventCloseOnClickOutside?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Modal = ({
|
||||
@@ -23,6 +24,7 @@ export const Modal = ({
|
||||
size = "md",
|
||||
showCloseButton = true,
|
||||
preventCloseOnClickOutside = false,
|
||||
className = "",
|
||||
}: ModalProps) => {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -90,10 +92,11 @@ export const Modal = ({
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={cn(
|
||||
"relative w-full bg-card border border-border shadow-lg overflow-y-auto",
|
||||
"relative w-full bg-card border border-border shadow-lg",
|
||||
"max-h-[85vh]",
|
||||
"sm:rounded-lg sm:max-h-[90vh] sm:w-full",
|
||||
sizeClasses[size]
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border sticky top-0 bg-card z-10">
|
||||
@@ -110,8 +113,10 @@ export const Modal = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6">{children}</div>
|
||||
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(80vh-100px)]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"refresh": "Refresh",
|
||||
"loading": "Loading"
|
||||
"loading": "Loading",
|
||||
"version": "{version}"
|
||||
},
|
||||
"cronjobs": {
|
||||
"cronJobs": "Cron Jobs",
|
||||
@@ -143,5 +144,24 @@
|
||||
"available": "Available",
|
||||
"systemStatus": "System Status",
|
||||
"lastUpdated": "Last updated"
|
||||
},
|
||||
"login": {
|
||||
"welcomeTitle": "Welcome to Cr*nMaster",
|
||||
"signInWithPasswordOrSSO": "Sign in with password or SSO",
|
||||
"signInWithSSO": "Sign in with SSO",
|
||||
"enterPasswordToContinue": "Enter your password to continue",
|
||||
"authenticationNotConfigured": "Authentication Not Configured",
|
||||
"noAuthMethodsEnabled": "Neither password authentication nor OIDC SSO is enabled. Please configure at least one authentication method in your environment variables to be able to log in.",
|
||||
"enterPassword": "Enter password",
|
||||
"signingIn": "Signing in...",
|
||||
"signIn": "Sign In",
|
||||
"redirecting": "Redirecting...",
|
||||
"orContinueWith": "Or continue with",
|
||||
"loginFailed": "Login failed",
|
||||
"genericError": "An error occurred. Please try again."
|
||||
},
|
||||
"warnings": {
|
||||
"wrapperScriptModified": "Wrapper Script Modified",
|
||||
"wrapperScriptModifiedDescription": "Your cron-log-wrapper.sh script has been modified from the official version. This may affect logging functionality. Consider reverting to the official version or ensure your changes don't break the required format for log parsing."
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
"optional": "Opzionale",
|
||||
"cancel": "Annulla",
|
||||
"refresh": "Aggiorna",
|
||||
"close": "Chiudi"
|
||||
"close": "Chiudi",
|
||||
"version": "{version}"
|
||||
},
|
||||
"cronjobs": {
|
||||
"cronJobs": "Operazioni Cron",
|
||||
@@ -142,5 +143,24 @@
|
||||
"available": "Disponibile",
|
||||
"systemStatus": "Stato del Sistema",
|
||||
"lastUpdated": "Ultimo aggiornamento"
|
||||
},
|
||||
"login": {
|
||||
"welcomeTitle": "Benvenuto in Cr*nMaster",
|
||||
"signInWithPasswordOrSSO": "Accedi con password o SSO",
|
||||
"signInWithSSO": "Accedi con SSO",
|
||||
"enterPasswordToContinue": "Inserisci la tua password per continuare",
|
||||
"authenticationNotConfigured": "Autenticazione Non Configurata",
|
||||
"noAuthMethodsEnabled": "Né l'autenticazione password né l'OIDC SSO sono abilitati. Si prega di configurare almeno un metodo di autenticazione nelle variabili d'ambiente per poter effettuare il login.",
|
||||
"enterPassword": "Inserisci password",
|
||||
"signingIn": "Accesso in corso...",
|
||||
"signIn": "Accedi",
|
||||
"redirecting": "Reindirizzamento...",
|
||||
"orContinueWith": "Oppure continua con",
|
||||
"loginFailed": "Accesso fallito",
|
||||
"genericError": "Si è verificato un errore. Riprova."
|
||||
},
|
||||
"warnings": {
|
||||
"wrapperScriptModified": "Script Wrapper Modificato",
|
||||
"wrapperScriptModifiedDescription": "Il tuo script cron-log-wrapper.sh è stato modificato dalla versione ufficiale. Questo potrebbe influenzare la funzionalità di logging. Considera di ripristinare la versione ufficiale o assicurati che le tue modifiche non interrompano il formato richiesto per l'analisi dei log."
|
||||
}
|
||||
}
|
||||
38
app/api/system/wrapper-check/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { DATA_DIR } from "@/app/_consts/file";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const officialScriptPath = path.join(
|
||||
process.cwd(),
|
||||
"app",
|
||||
"_scripts",
|
||||
"cron-log-wrapper.sh"
|
||||
);
|
||||
|
||||
const dataScriptPath = path.join(
|
||||
process.cwd(),
|
||||
DATA_DIR,
|
||||
"cron-log-wrapper.sh"
|
||||
);
|
||||
|
||||
if (!existsSync(dataScriptPath)) {
|
||||
return NextResponse.json({ modified: false });
|
||||
}
|
||||
|
||||
const officialScript = readFileSync(officialScriptPath, "utf-8");
|
||||
const dataScript = readFileSync(dataScriptPath, "utf-8");
|
||||
|
||||
const modified = officialScript !== dataScript;
|
||||
|
||||
return NextResponse.json({ modified });
|
||||
} catch (error) {
|
||||
console.error("Error checking wrapper script:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to check wrapper script" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -324,3 +324,17 @@
|
||||
margin-left: 4rem !important; /* 64px */
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-overflow-fix {
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-overflow-fix > * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-overflow-fix .dropdown-container,
|
||||
.dropdown-overflow-fix [class*="dropdown"] {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { LoginForm } from "@/app/_components/FeatureComponents/LoginForm/LoginForm";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export default async function LoginPage() {
|
||||
const hasPassword = !!process.env.AUTH_PASSWORD;
|
||||
const hasOIDC = process.env.SSO_MODE === "oidc";
|
||||
const hasPassword = !!process.env.AUTH_PASSWORD;
|
||||
const hasOIDC = process.env.SSO_MODE === "oidc";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
<div className="hero-gradient absolute inset-0 -z-10"></div>
|
||||
<div className="relative z-10 flex items-center justify-center min-h-screen p-4">
|
||||
<LoginForm hasPassword={hasPassword} hasOIDC={hasOIDC} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Read package.json to get version
|
||||
const packageJsonPath = path.join(process.cwd(), "package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
||||
const version = packageJson.version;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
<div className="hero-gradient absolute inset-0 -z-10"></div>
|
||||
<div className="relative z-10 flex items-center justify-center min-h-screen p-4">
|
||||
<LoginForm
|
||||
hasPassword={hasPassword}
|
||||
hasOIDC={hasOIDC}
|
||||
version={version}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ThemeToggle } from "@/app/_components/FeatureComponents/Theme/ThemeTogg
|
||||
import { LogoutButton } from "@/app/_components/FeatureComponents/LoginForm/LogoutButton";
|
||||
import { ToastContainer } from "@/app/_components/GlobalComponents/UIElements/Toast";
|
||||
import { PWAInstallPrompt } from "@/app/_components/FeatureComponents/PWA/PWAInstallPrompt";
|
||||
import { WrapperScriptWarning } from "@/app/_components/FeatureComponents/System/WrapperScriptWarning";
|
||||
import { getTranslations } from "@/app/_server/actions/translations";
|
||||
import { SSEProvider } from "@/app/_contexts/SSEContext";
|
||||
|
||||
@@ -93,6 +94,7 @@ export default async function Home() {
|
||||
|
||||
<main className="lg:ml-80 transition-all duration-300 ml-0 sidebar-collapsed:lg:ml-16">
|
||||
<div className="container mx-auto px-4 py-8 lg:px-8">
|
||||
<WrapperScriptWarning />
|
||||
<TabbedInterface cronJobs={cronJobs} scripts={scripts} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
BIN
screenshots/backup.png
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
screenshots/home.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 729 KiB |
BIN
screenshots/live-running.png
Normal file
|
After Width: | Height: | Size: 355 KiB |
BIN
screenshots/logs.png
Normal file
|
After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 510 KiB |