WIP auth and pwa integration

This commit is contained in:
fccview
2025-08-31 21:19:51 +01:00
parent 44b31a5702
commit 0b9edc5f11
19 changed files with 2282 additions and 48 deletions

View File

@@ -49,7 +49,7 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
```bash
services:
cronjob-manager:
image: ghcr.io/fccview/cronmaster:1.3.1
image: ghcr.io/fccview/cronmaster:1.4.0
container_name: cronmaster
user: "root"
ports:
@@ -58,17 +58,26 @@ services:
environment:
- NODE_ENV=production
- DOCKER=true
# Legacy used to be NEXT_PUBLIC_HOST_PROJECT_DIR, this was causing issues on runtime.
# --- MAP HOST PROJECT DIRECTORY, THIS IS MANDATORY FOR SCRIPTS TO WORK
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
# If docker struggles to find your crontab user, update this variable with it.
# Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
# --- PASSWORD PROTECTION
# Uncomment to enable password protection (replace "password" with your own)
#- AUTH_PASSWORD=password
# --- CRONTAB USERS
# This is used to read the crontabs for the specific user.
# replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=fccview,root,user1,user2
# - HOST_CRONTAB_USER=fccview
volumes:
# --- MOUNT DOCKER SOCKET
# Mount Docker socket to execute commands on host
- /var/run/docker.sock:/var/run/docker.sock
# --- MOUNT DATA
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
@@ -76,14 +85,14 @@ services:
- ./data:/app/data
- ./snippets:/app/snippets
# Use host PID namespace for host command execution
# Run in privileged mode for nsenter access
# --- USE HOST PID NAMESPACE FOR HOST COMMAND EXECUTION
# --- RUN IN PRIVILEGED MODE FOR NSENTER ACCESS
pid: "host"
privileged: true
restart: unless-stopped
init: true
# Default platform is set to amd64, uncomment to use arm64.
# --- DEFAULT PLATFORM IS SET TO AMD64, UNCOMMENT TO USE ARM64.
#platform: linux/arm64
```
@@ -244,6 +253,9 @@ I would like to thank the following members for raising issues and help test/deb
<td align="center" valign="top" width="20%">
<a href="https://github.com/ActxLeToucan"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/56509120?u=b0a684dfa1fcf8f3f41c2ead37f6441716d8bd62&v=4&size=100"><br />ActxLeToucan</a>
</td>
<td align="center" valign="top" width="20%">
<a href="https://github.com/mrtimothyduong"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/34667840?u=b54354da56681c17ca58366a68a6a94c80f77a1d&v=4&size=100"><br />mrtimothyduong</a>
</td>
</tr>
</tbody>
</table>

View File

@@ -159,14 +159,8 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
const result = await runCronJob(id);
if (result.success) {
showToast("success", "Cron job executed successfully");
if (result.output) {
console.log("Command output:", result.output);
}
} else {
showToast("error", "Failed to execute cron job", result.message);
if (result.output) {
console.error("Command error:", result.output);
}
}
} catch (error) {
showToast(

View File

@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "../../ui/Button";
import { Input } from "../../ui/Input";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "../../ui/Card";
import { Lock, Eye, EyeOff } from "lucide-react";
export const LoginForm = () => {
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ password }),
});
const result = await response.json();
if (result.success) {
router.push("/");
} else {
setError(result.message || "Login failed");
}
} catch (error) {
setError("An error occurred. Please try again.");
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full max-w-md shadow-xl">
<CardHeader className="text-center">
<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>
<CardDescription>Enter your password to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
className="pr-10"
required
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
disabled={isLoading}
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
{error && (
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-md p-3">
{error}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading || !password.trim()}
>
{isLoading ? "Signing in..." : "Sign In"}
</Button>
</form>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "./Button";
import { LogOut } from "lucide-react";
export const LogoutButton = () => {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
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);
}
};
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>
);
};

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
try {
const { password } = await request.json()
const authPassword = process.env.AUTH_PASSWORD
if (!authPassword) {
return NextResponse.json(
{ success: false, message: 'Authentication not configured' },
{ status: 400 }
)
}
if (password !== authPassword) {
return NextResponse.json(
{ success: false, message: 'Invalid password' },
{ status: 401 }
)
}
const response = NextResponse.json(
{ success: true, message: 'Login successful' },
{ status: 200 }
)
response.cookies.set('cronmaster-auth', 'authenticated', {
httpOnly: true,
secure: request.url.startsWith('https://'),
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return response
} catch (error) {
console.error('Login error:', error)
return NextResponse.json(
{ success: false, message: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
try {
const response = NextResponse.json(
{ success: true, message: 'Logout successful' },
{ status: 200 }
)
response.cookies.set('cronmaster-auth', '', {
httpOnly: true,
secure: request.url.startsWith('https://'),
sameSite: 'lax',
maxAge: 0,
path: '/',
})
return response
} catch (error) {
console.error('Logout error:', error)
return NextResponse.json(
{ success: false, message: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -74,7 +74,7 @@ export async function GET(request: NextRequest) {
statusDetails = "Moderate resource usage - monitoring recommended";
}
let mainInterface = null;
let mainInterface: any = null;
if (Array.isArray(networkInfo) && networkInfo.length > 0) {
mainInterface = networkInfo.find(net =>
net.iface && !net.iface.includes('lo') && net.operstate === 'up'

View File

@@ -17,8 +17,18 @@ const inter = Inter({
export const metadata: Metadata = {
title: "Cr*nMaster - Cron Management made easy",
description:
"The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
description: "The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
manifest: "/manifest.json",
themeColor: "#3b82f6",
viewport: "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Cr*nMaster",
},
formatDetection: {
telephone: false,
},
icons: {
icon: "/logo.png",
shortcut: "/logo.png",
@@ -33,10 +43,18 @@ export default function RootLayout({
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta name="application-name" content="Cr*nMaster" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Cr*nMaster" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="/icon-192x192.png" />
</head>
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans`}>
<ThemeProvider
attribute="class"
defaultTheme="system"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>

14
app/login/page.tsx Normal file
View File

@@ -0,0 +1,14 @@
'use server';
import { LoginForm } from "../_components/features/LoginForm/LoginForm";
export default async function LoginPage() {
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 />
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { TabbedInterface } from "./_components/TabbedInterface";
import { getCronJobs } from "./_utils/system";
import { fetchScripts } from "./_server/actions/scripts";
import { ThemeToggle } from "./_components/ui/ThemeToggle";
import { LogoutButton } from "./_components/ui/LogoutButton";
import { ToastContainer } from "./_components/ui/Toast";
export const dynamic = "force-dynamic";
@@ -52,7 +53,7 @@ export default async function Home() {
<div className="relative z-10">
<header className="border-b border-border/50 bg-background/80 backdrop-blur-md sticky top-0 z-20 shadow-sm lg:h-[90px]">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-center">
<div className="flex items-center justify-between lg:justify-center">
<div className="flex items-center gap-4">
<div className="relative">
<img src="/logo.png" alt="logo" className="w-14 h-14" />
@@ -67,6 +68,11 @@ export default async function Home() {
</p>
</div>
</div>
{process.env.AUTH_PASSWORD && (
<div className="lg:absolute lg:right-10">
<LogoutButton />
</div>
)}
</div>
</div>
</header>
@@ -82,7 +88,7 @@ export default async function Home() {
<ToastContainer />
<div className="flex items-center gap-2 fixed bottom-4 left-4 lg:right-4 lg:left-auto z-10 bg-background rounded-lg">
<div className="flex items-center gap-2 fixed bottom-4 left-4 lg:right-4 lg:left-auto z-10 bg-background/80 backdrop-blur-md border border-border/50 rounded-lg p-1">
<ThemeToggle />
</div>
</div>

View File

@@ -1,6 +1,6 @@
services:
cronjob-manager:
image: ghcr.io/fccview/cronmaster:1.3.1
image: ghcr.io/fccview/cronmaster:1.4.0
container_name: cronmaster
user: "root"
ports:
@@ -9,17 +9,26 @@ services:
environment:
- NODE_ENV=production
- DOCKER=true
# Legacy used to be NEXT_PUBLIC_HOST_PROJECT_DIR, this was causing issues on runtime.
# --- MAP HOST PROJECT DIRECTORY, THIS IS MANDATORY FOR SCRIPTS TO WORK
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
# If docker struggles to find your crontab user, update this variable with it.
# Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
# --- PASSWORD PROTECTION
# Uncomment to enable password protection (replace "password" with your own)
#- AUTH_PASSWORD=password
# --- CRONTAB USERS
# This is used to read the crontabs for the specific user.
# replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
# For multiple users, use comma-separated values: HOST_CRONTAB_USER=fccview,root,user1,user2
# - HOST_CRONTAB_USER=fccview
volumes:
# --- MOUNT DOCKER SOCKET
# Mount Docker socket to execute commands on host
- /var/run/docker.sock:/var/run/docker.sock
# --- MOUNT DATA
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
@@ -27,12 +36,12 @@ services:
- ./data:/app/data
- ./snippets:/app/snippets
# Use host PID namespace for host command execution
# Run in privileged mode for nsenter access
# --- USE HOST PID NAMESPACE FOR HOST COMMAND EXECUTION
# --- RUN IN PRIVILEGED MODE FOR NSENTER ACCESS
pid: "host"
privileged: true
restart: unless-stopped
init: true
# Default platform is set to amd64, uncomment to use arm64.
# --- DEFAULT PLATFORM IS SET TO AMD64, UNCOMMENT TO USE ARM64.
#platform: linux/arm64

37
middleware.ts Normal file
View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
if (pathname.startsWith('/api/') || pathname.startsWith('/_next/') || pathname.includes('.')) {
return NextResponse.next()
}
const authPassword = process.env.AUTH_PASSWORD
if (!authPassword) {
return NextResponse.next()
}
const isAuthenticated = request.cookies.has('cronmaster-auth')
if (pathname === '/login') {
if (isAuthenticated || !authPassword) {
return NextResponse.redirect(new URL('/', request.url))
}
return NextResponse.next()
}
if (!isAuthenticated) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}

View File

@@ -1,6 +1,12 @@
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development'
})
/** @type {import('next').NextConfig} */
const nextConfig = {
}
module.exports = nextConfig
module.exports = withPWA(nextConfig)

View File

@@ -23,12 +23,15 @@
"@types/react-dom": "^18",
"@types/react-syntax-highlighter": "^15.5.13",
"autoprefixer": "^10.0.1",
"bcryptjs": "^2.4.3",
"clsx": "^2.0.0",
"codemirror": "^6.0.2",
"cron-parser": "^5.3.0",
"cronstrue": "^3.2.0",
"lucide-react": "^0.294.0",
"minimatch": "^10.0.3",
"next": "14.0.4",
"next-pwa": "^5.6.0",
"next-themes": "^0.2.1",
"postcss": "^8",
"react": "^18",
@@ -40,6 +43,8 @@
"typescript": "^5"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/minimatch": "^6.0.0",
"eslint": "^8",
"eslint-config-next": "14.0.4"
}

26
public/manifest.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "Cr*nMaster",
"short_name": "Cr*nMaster",
"description": "The ultimate cron job management platform with intelligent scheduling, real-time monitoring, and powerful automation tools",
"start_url": "/",
"display": "standalone",
"background_color": "#0f0f23",
"theme_color": "#3b82f6",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"categories": ["productivity", "utilities"],
"lang": "en"
}

1
public/sw.js Normal file
View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "es6"],
"lib": [
"dom",
"dom.iterable",
"es6"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -19,9 +23,18 @@
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

1912
yarn.lock
View File

File diff suppressed because it is too large Load Diff