diff --git a/README.md b/README.md index 0fa9928..8dcc625 100644 --- a/README.md +++ b/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

- - + +
@@ -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)** @@ -126,6 +127,14 @@ services: 📖 **For the complete SSO documentation, see [howto/SSO.md](howto/SSO.md)** + + +## 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 diff --git a/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx b/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx index a7ae1e2..01ac95c 100644 --- a/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx +++ b/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx @@ -301,7 +301,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => { onNewTaskClick={() => setIsNewCronModalOpen(true)} /> ) : ( -
+
{loadedSettings ? ( filteredJobs.map((job) => minimalMode ? ( diff --git a/app/_components/FeatureComponents/LoginForm/LoginForm.tsx b/app/_components/FeatureComponents/LoginForm/LoginForm.tsx index b629c4e..b611c98 100644 --- a/app/_components/FeatureComponents/LoginForm/LoginForm.tsx +++ b/app/_components/FeatureComponents/LoginForm/LoginForm.tsx @@ -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
- Welcome to Cr*nMaster + {t("login.welcomeTitle")} {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")} + {!hasPassword && !hasOIDC && ( +
+
+ +
+
+ {t("login.authenticationNotConfigured")} +
+
{t("login.noAuthMethodsEnabled")}
+
+
+
+ )} +
{hasPassword && (
@@ -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")}
)} @@ -113,7 +140,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
- Or continue with + {t("login.orContinueWith")}
@@ -128,7 +155,7 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro disabled={isLoading} > - {isLoading ? "Redirecting..." : "Sign in with SSO"} + {isLoading ? t("login.redirecting") : t("login.signInWithSSO")} )} @@ -138,6 +165,14 @@ export const LoginForm = ({ hasPassword = false, hasOIDC = false }: LoginFormPro
)} + + {version && ( +
+
+ Cr*nMaster {t("common.version", { version })} +
+
+ )} ); diff --git a/app/_components/FeatureComponents/LoginForm/LogoutButton.tsx b/app/_components/FeatureComponents/LoginForm/LogoutButton.tsx index 7fdae28..d3c05d4 100644 --- a/app/_components/FeatureComponents/LoginForm/LogoutButton.tsx +++ b/app/_components/FeatureComponents/LoginForm/LogoutButton.tsx @@ -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 ( - - ); + return ( + + ); }; diff --git a/app/_components/FeatureComponents/Modals/FiltersModal.tsx b/app/_components/FeatureComponents/Modals/FiltersModal.tsx index ddb9514..c437f9a 100644 --- a/app/_components/FeatureComponents/Modals/FiltersModal.tsx +++ b/app/_components/FeatureComponents/Modals/FiltersModal.tsx @@ -46,7 +46,7 @@ export const FiltersModal = ({ size="md" >
-
+
-
+
@@ -150,4 +150,4 @@ export const ScriptModal = ({ ); -} +}; diff --git a/app/_components/FeatureComponents/Scripts/BashSnippetHelper.tsx b/app/_components/FeatureComponents/Scripts/BashSnippetHelper.tsx index 4746e19..e60e672 100644 --- a/app/_components/FeatureComponents/Scripts/BashSnippetHelper.tsx +++ b/app/_components/FeatureComponents/Scripts/BashSnippetHelper.tsx @@ -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(null); const [copiedId, setCopiedId] = useState(null); @@ -161,7 +163,7 @@ export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) =
)} -
+
{filteredSnippets.map((snippet) => { const Icon = categoryIcons[snippet.category as keyof typeof categoryIcons] || @@ -243,4 +245,4 @@ export const BashSnippetHelper = ({ onInsertSnippet }: BashSnippetHelperProps) =
); -} +}; diff --git a/app/_components/FeatureComponents/System/WrapperScriptWarning.tsx b/app/_components/FeatureComponents/System/WrapperScriptWarning.tsx new file mode 100644 index 0000000..8123aa1 --- /dev/null +++ b/app/_components/FeatureComponents/System/WrapperScriptWarning.tsx @@ -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 ( +
+
+
+ +
+

+ {t("warnings.wrapperScriptModified")} +

+

+ {t("warnings.wrapperScriptModifiedDescription")} +

+
+
+ +
+
+ ); +}; diff --git a/app/_components/FeatureComponents/User/UserSwitcher.tsx b/app/_components/FeatureComponents/User/UserSwitcher.tsx index 32f997d..ece53a7 100644 --- a/app/_components/FeatureComponents/User/UserSwitcher.tsx +++ b/app/_components/FeatureComponents/User/UserSwitcher.tsx @@ -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} @@ -82,4 +83,4 @@ export const UserSwitcher = ({ )}
); -} +}; diff --git a/app/_components/GlobalComponents/UIElements/DropdownMenu.tsx b/app/_components/GlobalComponents/UIElements/DropdownMenu.tsx index 14e38f7..43595f1 100644 --- a/app/_components/GlobalComponents/UIElements/DropdownMenu.tsx +++ b/app/_components/GlobalComponents/UIElements/DropdownMenu.tsx @@ -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(null); + const triggerRef = useRef(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 (
{isOpen && ( -
+
{items.map((item, index) => ( ))} diff --git a/app/_components/GlobalComponents/UIElements/Modal.tsx b/app/_components/GlobalComponents/UIElements/Modal.tsx index 21ddbed..cdc4abd 100644 --- a/app/_components/GlobalComponents/UIElements/Modal.tsx +++ b/app/_components/GlobalComponents/UIElements/Modal.tsx @@ -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(null); @@ -90,10 +92,11 @@ export const Modal = ({
@@ -110,8 +113,10 @@ export const Modal = ({ )}
-
{children}
+
+ {children} +
); -} +}; diff --git a/app/_translations/en.json b/app/_translations/en.json index ede1747..a9dc4bc 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -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." } } \ No newline at end of file diff --git a/app/_translations/it.json b/app/_translations/it.json index b873f65..47ce22c 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -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." } } \ No newline at end of file diff --git a/app/api/system/wrapper-check/route.ts b/app/api/system/wrapper-check/route.ts new file mode 100644 index 0000000..dd7864f --- /dev/null +++ b/app/api/system/wrapper-check/route.ts @@ -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 } + ); + } +} diff --git a/app/globals.css b/app/globals.css index a83756b..9d2860c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 8971d38..9f2ecb2 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -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 ( -
-
-
- -
-
- ); + // 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 ( +
+
+
+ +
+
+ ); } diff --git a/app/page.tsx b/app/page.tsx index bebb49e..314b4aa 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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() {
+
diff --git a/screenshots/backup.png b/screenshots/backup.png new file mode 100644 index 0000000..657bbef Binary files /dev/null and b/screenshots/backup.png differ diff --git a/screenshots/home.png b/screenshots/home.png new file mode 100644 index 0000000..2dd3905 Binary files /dev/null and b/screenshots/home.png differ diff --git a/screenshots/jobs-view.png b/screenshots/jobs-view.png deleted file mode 100644 index a3cb2b3..0000000 Binary files a/screenshots/jobs-view.png and /dev/null differ diff --git a/screenshots/live-running.png b/screenshots/live-running.png new file mode 100644 index 0000000..fa3a92c Binary files /dev/null and b/screenshots/live-running.png differ diff --git a/screenshots/logs.png b/screenshots/logs.png new file mode 100644 index 0000000..19228a4 Binary files /dev/null and b/screenshots/logs.png differ diff --git a/screenshots/new-job-script-confirm.png b/screenshots/new-job-script-confirm.png deleted file mode 100644 index 7005bdb..0000000 Binary files a/screenshots/new-job-script-confirm.png and /dev/null differ diff --git a/screenshots/new-job-script.png b/screenshots/new-job-script.png deleted file mode 100644 index 03eeb7e..0000000 Binary files a/screenshots/new-job-script.png and /dev/null differ diff --git a/screenshots/new-job.png b/screenshots/new-job.png index ff470c3..f5eee38 100644 Binary files a/screenshots/new-job.png and b/screenshots/new-job.png differ diff --git a/screenshots/new-script.png b/screenshots/new-script.png deleted file mode 100644 index 30f9d49..0000000 Binary files a/screenshots/new-script.png and /dev/null differ diff --git a/screenshots/scripts-view.png b/screenshots/scripts-view.png deleted file mode 100644 index ba0e9a8..0000000 Binary files a/screenshots/scripts-view.png and /dev/null differ