feat: enhance visitor mode

This commit is contained in:
Peifan Li
2026-01-03 21:47:54 -05:00
parent 44b24543d0
commit 76d4269164
40 changed files with 941 additions and 398 deletions

View File

@@ -20,6 +20,7 @@
"drizzle-orm": "^0.44.7",
"express": "^4.22.0",
"fs-extra": "^11.2.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.0.2",
"node-cron": "^4.2.1",
"puppeteer": "^24.31.0",
@@ -31,6 +32,7 @@
"@types/cors": "^2.8.19",
"@types/express": "^5.0.5",
"@types/fs-extra": "^11.0.4",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.1",
"@types/node-cron": "^3.0.11",
@@ -1536,6 +1538,17 @@
"@types/node": "*"
}
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -1550,6 +1563,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
@@ -2367,6 +2387,12 @@
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -3139,6 +3165,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -4455,12 +4490,103 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",

View File

@@ -27,6 +27,7 @@
"drizzle-orm": "^0.44.7",
"express": "^4.22.0",
"fs-extra": "^11.2.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.0.2",
"node-cron": "^4.2.1",
"puppeteer": "^24.31.0",
@@ -38,6 +39,7 @@
"@types/cors": "^2.8.19",
"@types/express": "^5.0.5",
"@types/fs-extra": "^11.0.4",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.1",
"@types/node-cron": "^3.0.11",

View File

@@ -60,4 +60,34 @@ describe('visitorModeMiddleware', () => {
expect(next).toHaveBeenCalled();
});
it('should allow passkey authenticate endpoint', () => {
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
mockReq = {
method: 'POST',
body: {},
path: '/settings/passkeys/authenticate',
url: '/settings/passkeys/authenticate'
};
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
expect(next).toHaveBeenCalled();
});
it('should allow passkey verify endpoint', () => {
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
mockReq = {
method: 'POST',
body: {},
path: '/settings/passkeys/authenticate/verify',
url: '/settings/passkeys/authenticate/verify'
};
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
expect(next).toHaveBeenCalled();
});
});

View File

@@ -37,11 +37,47 @@ describe('visitorModeSettingsMiddleware', () => {
it('should block other updates', () => {
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
mockReq.body = { websiteName: 'Hacked' };
mockReq = {
method: 'POST',
body: { websiteName: 'Hacked' },
path: '/api/settings',
url: '/api/settings'
};
visitorModeSettingsMiddleware(mockReq as Request, mockRes as Response, next);
expect(next).not.toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(403);
});
it('should allow passkey authenticate endpoint', () => {
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
mockReq = {
method: 'POST',
body: {},
path: '/passkeys/authenticate',
url: '/passkeys/authenticate'
};
visitorModeSettingsMiddleware(mockReq as Request, mockRes as Response, next);
expect(next).toHaveBeenCalled();
});
it('should allow passkey verify endpoint', () => {
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
mockReq = {
method: 'POST',
body: {},
path: '/passkeys/authenticate/verify',
url: '/passkeys/authenticate/verify'
};
visitorModeSettingsMiddleware(mockReq as Request, mockRes as Response, next);
expect(next).toHaveBeenCalled();
});
});

View File

@@ -164,7 +164,7 @@ export const verifyAuthentication = async (
);
if (result.verified) {
res.json({ success: true });
res.json({ success: true, token: result.token, role: result.role });
} else {
res.status(401).json({ success: false, error: "Authentication failed" });
}

View File

@@ -27,15 +27,22 @@ export const verifyPassword = async (
const result = await passwordService.verifyPassword(password);
if (result.success) {
// Return format expected by frontend: { success: boolean }
res.json({ success: true });
// Return format expected by frontend: { success: boolean, role?, token? }
res.json({
success: true,
role: result.role,
token: result.token
});
} else {
// Return wait time information
res.status(result.waitTime ? 429 : 401).json({
// Return 200 OK to suppress browser console errors, but include status code and success: false
const statusCode = result.waitTime ? 429 : 401;
res.json({
success: false,
waitTime: result.waitTime,
failedAttempts: result.failedAttempts,
message: result.message,
statusCode
});
}
};

View File

@@ -2,9 +2,9 @@ import { Request, Response } from "express";
import fs from "fs-extra";
import path from "path";
import {
COLLECTIONS_DATA_PATH,
STATUS_DATA_PATH,
VIDEOS_DATA_PATH,
COLLECTIONS_DATA_PATH,
STATUS_DATA_PATH,
VIDEOS_DATA_PATH,
} from "../config/paths";
import { cloudflaredService } from "../services/cloudflaredService";
import downloadManager from "../services/downloadManager";
@@ -37,9 +37,9 @@ export const getSettings = async (
const mergedSettings = { ...defaultSettings, ...settings };
// Do not send the hashed password to the frontend
const { password, ...safeSettings } = mergedSettings;
const { password, visitorPassword, ...safeSettings } = mergedSettings;
// Return data directly for backward compatibility
res.json({ ...safeSettings, isPasswordSet: !!password });
res.json({ ...safeSettings, isPasswordSet: !!password, isVisitorPasswordSet: !!visitorPassword });
};
/**
@@ -124,35 +124,43 @@ export const updateSettings = async (
{}
);
// Check visitor mode restrictions
const visitorModeCheck =
settingsValidationService.checkVisitorModeRestrictions(
mergedSettings,
newSettings
);
// Check visitor mode restrictions (if not admin)
// If user is admin (jwt authenticated), they bypass visitor mode restrictions
const isAdmin = req.user?.role === "admin";
if (!isAdmin) {
const visitorModeCheck =
settingsValidationService.checkVisitorModeRestrictions(
mergedSettings,
newSettings
);
if (!visitorModeCheck.allowed) {
res.status(403).json({
success: false,
error: visitorModeCheck.error,
});
return;
if (!visitorModeCheck.allowed) {
res.status(403).json({
success: false,
error: visitorModeCheck.error,
});
return;
}
// Handle special case: visitorMode being set to true (already enabled)
// Only applies if NOT admin (admins can update settings while in visitor mode)
if (mergedSettings.visitorMode === true && newSettings.visitorMode === true) {
// Only update visitorMode, ignore other changes
const allowedSettings: Settings = {
...mergedSettings,
visitorMode: true,
};
storageService.saveSettings(allowedSettings);
res.json({
success: true,
settings: { ...allowedSettings, password: undefined, visitorPassword: undefined },
});
return;
}
}
// Handle special case: visitorMode being set to true (already enabled)
if (mergedSettings.visitorMode === true && newSettings.visitorMode === true) {
// Only update visitorMode, ignore other changes
const allowedSettings: Settings = {
...mergedSettings,
visitorMode: true,
};
storageService.saveSettings(allowedSettings);
res.json({
success: true,
settings: { ...allowedSettings, password: undefined },
});
return;
}
// Validate settings
settingsValidationService.validateSettings(newSettings);
@@ -253,7 +261,7 @@ export const updateSettings = async (
// Return format expected by frontend: { success: true, settings: {...} }
res.json({
success: true,
settings: { ...finalSettings, password: undefined },
settings: { ...finalSettings, password: undefined, visitorPassword: undefined },
});
};

View File

@@ -0,0 +1,35 @@
import { NextFunction, Request, Response } from "express";
import { UserPayload, verifyToken } from "../services/authService";
// Extend Express Request type to include user property
declare global {
namespace Express {
interface Request {
user?: UserPayload;
}
}
}
/**
* Middleware to verify JWT token and attach user to request
* Does NOT block requests if token is missing/invalid, just leaves req.user undefined
* Blocking logic should be handled by specific route guards or visitorModeMiddleware
*/
export const authMiddleware = (
req: Request,
_res: Response,
next: NextFunction
): void => {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.split(" ")[1];
const decoded = verifyToken(token);
if (decoded) {
req.user = decoded;
}
}
next();
};

View File

@@ -19,6 +19,12 @@ export const visitorModeMiddleware = (
return;
}
// If user is Admin, allow all requests
if (req.user?.role === "admin") {
next();
return;
}
// Visitor mode is enabled
// Allow GET requests (read-only)
if (req.method === "GET") {
@@ -37,6 +43,12 @@ export const visitorModeMiddleware = (
return;
}
// Allow passkey authentication
if (req.path.includes("/settings/passkeys/authenticate") || req.url.includes("/settings/passkeys/authenticate")) {
next();
return;
}
// Check if the request is trying to disable visitor mode
if (body.visitorMode === false) {
// Allow disabling visitor mode

View File

@@ -19,6 +19,12 @@ export const visitorModeSettingsMiddleware = (
return;
}
// If user is Admin, allow all requests
if (req.user?.role === "admin") {
next();
return;
}
// Visitor mode is enabled
// Allow GET requests (read-only)
if (req.method === "GET") {
@@ -37,6 +43,15 @@ export const visitorModeSettingsMiddleware = (
return;
}
// Allow passkey authentication
if (
req.path.includes("/passkeys/authenticate") ||
req.url.includes("/passkeys/authenticate")
) {
next();
return;
}
const body = req.body || {};
// Check if the request is trying to disable visitor mode
if (body.visitorMode === false) {

View File

@@ -12,6 +12,7 @@ import {
VIDEOS_DIR,
} from "./config/paths";
import { runMigrations } from "./db/migrate";
import { authMiddleware } from "./middleware/authMiddleware";
import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware";
import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware";
import apiRoutes from "./routes/api";
@@ -243,6 +244,8 @@ const startServer = async () => {
);
// API Routes
// Apply auth middleware to all API routes
app.use("/api", authMiddleware);
// Apply visitor mode middleware to all API routes
app.use("/api", visitorModeMiddleware, apiRoutes);
// Use separate middleware for settings that allows disabling visitor mode

View File

@@ -35,6 +35,10 @@ vi.mock("../../utils/logger", () => ({
},
}));
vi.mock("../authService", () => ({
generateToken: vi.fn(() => "mock-token"),
}));
describe("passkeyService", () => {
const mockPasskey = {
credentialID: "mock-credential-id",
@@ -251,6 +255,18 @@ describe("passkeyService", () => {
])
})
);
expect(storageService.saveSettings).toHaveBeenCalledWith(
expect.objectContaining({
passkeys: expect.arrayContaining([
expect.objectContaining({
credentialID: "mock-credential-id",
counter: 1
})
])
})
);
expect(result.token).toBe("mock-token");
expect(result.role).toBe("admin");
});
it("should fail if passkey not found", async () => {

View File

@@ -0,0 +1,31 @@
import jwt from "jsonwebtoken";
import { v4 as uuidv4 } from "uuid";
const JWT_SECRET = process.env.JWT_SECRET || "default_development_secret_do_not_use_in_production";
const JWT_EXPIRES_IN = "24h";
export interface UserPayload {
role: "admin" | "visitor";
id?: string;
}
/**
* Generate a JWT token for a user
*/
export const generateToken = (payload: UserPayload): string => {
return jwt.sign({ ...payload, id: payload.id || uuidv4() }, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
};
/**
* Verify a JWT token
*/
export const verifyToken = (token: string): UserPayload | null => {
try {
const decoded = jwt.verify(token, JWT_SECRET) as UserPayload;
return decoded;
} catch (error) {
return null;
}
};

View File

@@ -11,6 +11,7 @@ import {
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { logger } from "../utils/logger";
import { generateToken } from "./authService";
import * as storageService from "./storageService";
// RP (Relying Party) configuration
@@ -255,6 +256,10 @@ export async function generatePasskeyAuthenticationOptions(
};
}
/**
* Verify passkey authentication
*/
/**
* Verify passkey authentication
*/
@@ -263,7 +268,7 @@ export async function verifyPasskeyAuthentication(
challenge: string,
originOverride?: string,
rpIDOverride?: string
): Promise<{ verified: boolean }> {
): Promise<{ verified: boolean; token?: string; role?: "admin" | "visitor" }> {
try {
const passkeys = getPasskeys();
// Find passkey by matching the credential ID
@@ -304,7 +309,11 @@ export async function verifyPasskeyAuthentication(
savePasskeys(updatedPasskeys);
logger.info("Passkey authentication successful");
return { verified: true };
// Generate admin token (Passkeys are currently only for admins)
const token = generateToken({ role: "admin" });
return { verified: true, token, role: "admin" };
}
return { verified: false };

View File

@@ -1,9 +1,9 @@
import bcrypt from "bcryptjs";
import crypto from "crypto";
import { defaultSettings } from "../types/settings";
import { logger } from "../utils/logger";
import * as loginAttemptService from "./loginAttemptService";
import * as storageService from "./storageService";
import { logger } from "../utils/logger";
import { Settings, defaultSettings } from "../types/settings";
/**
* Check if login is required (loginEnabled is true)
@@ -44,10 +44,17 @@ export function isPasswordEnabled(): {
/**
* Verify password for authentication
*/
/**
* Verify password for authentication
*/
import { generateToken } from "./authService";
export async function verifyPassword(
password: string
): Promise<{
success: boolean;
role?: "admin" | "visitor";
token?: string;
waitTime?: number;
failedAttempts?: number;
message?: string;
@@ -58,6 +65,11 @@ export async function verifyPassword(
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
// If password login is explicitly disabled, ONLY allow Admin to login via password (if they have one set)?
// Or just block everyone? The frontend says "When disabled, password login is not available."
// But typically Admin should always be able to login?
// For now, let's respect the flag, but maybe we should allow it if we are matching the Admin password?
// Let's stick to current logic: if blocked, blocked.
if (!passwordLoginAllowed) {
return {
success: false,
@@ -65,11 +77,6 @@ export async function verifyPassword(
};
}
if (!mergedSettings.password) {
// If no password set but login enabled, allow access
return { success: true };
}
// Check if user can attempt login (wait time check)
const remainingWaitTime = loginAttemptService.canAttemptLogin();
if (remainingWaitTime > 0) {
@@ -81,24 +88,49 @@ export async function verifyPassword(
};
}
const isMatch = await bcrypt.compare(password, mergedSettings.password);
if (isMatch) {
// Reset failed attempts on successful login
loginAttemptService.resetFailedAttempts();
return { success: true };
// 1. Check Admin Password
if (mergedSettings.password) {
const isAdminMatch = await bcrypt.compare(password, mergedSettings.password);
if (isAdminMatch) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "admin" });
return { success: true, role: "admin", token };
}
} else {
// Record failed attempt and get wait time
const waitTime = loginAttemptService.recordFailedAttempt();
const failedAttempts = loginAttemptService.getFailedAttempts();
return {
success: false,
waitTime,
failedAttempts,
message: "Incorrect password",
};
// If no admin password set, and login enabled, allow as admin
if (mergedSettings.loginEnabled) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "admin" });
return { success: true, role: "admin", token };
}
}
// 2. Check Visitor Password (only if visitorMode is enabled AND visitorPassword is set)
// Actually, user said: "When visitor user enable... login from this password input user role is Visitor"
// So we check if visitorMode is enabled in settings.
if (mergedSettings.visitorMode && mergedSettings.visitorPassword) {
// NOTE: visitorPassword might not be hashed yet if we just added it?
// Step 6 in Plan said "Update Settings type", but we didn't discuss hashing.
// We should probably hash it when saving too.
// For now, assuming it WILL be hashed. I'll need to update `settingsValidationService` to hash it.
const isVisitorMatch = await bcrypt.compare(password, mergedSettings.visitorPassword);
if (isVisitorMatch) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "visitor" });
return { success: true, role: "visitor", token };
}
}
// No match
const waitTime = loginAttemptService.recordFailedAttempt();
const failedAttempts = loginAttemptService.getFailedAttempts();
return {
success: false,
waitTime,
failedAttempts,
message: "Incorrect password",
};
}
/**

View File

@@ -1,6 +1,6 @@
import * as storageService from "./storageService";
import { Settings, defaultSettings } from "../types/settings";
import { logger } from "../utils/logger";
import * as storageService from "./storageService";
/**
* Validate and normalize settings values
@@ -155,6 +155,13 @@ export async function prepareSettingsForSave(
prepared.password = existingSettings.password;
}
// Handle visitor password hashing
if (prepared.visitorPassword) {
prepared.visitorPassword = await hashPassword(prepared.visitorPassword);
} else {
prepared.visitorPassword = existingSettings.visitorPassword;
}
// Handle tags
const oldTags: string[] = existingSettings.tags || [];
if (prepared.tags === undefined) {

View File

@@ -24,6 +24,7 @@ export interface Settings {
moveSubtitlesToVideoFolder?: boolean;
moveThumbnailsToVideoFolder?: boolean;
visitorMode?: boolean;
visitorPassword?: string;
infiniteScroll?: boolean;
videoColumns?: number;
cloudflaredTunnelEnabled?: boolean;

View File

@@ -147,8 +147,8 @@ function App() {
<ThemeContextProvider>
<LanguageProvider>
<SnackbarProvider>
<VisitorModeProvider>
<AuthProvider>
<AuthProvider>
<VisitorModeProvider>
<VideoProvider>
<CollectionProvider>
<DownloadProvider>
@@ -156,8 +156,8 @@ function App() {
</DownloadProvider>
</CollectionProvider>
</VideoProvider>
</AuthProvider>
</VisitorModeProvider>
</VisitorModeProvider>
</AuthProvider>
</SnackbarProvider>
</LanguageProvider>
</ThemeContextProvider>

View File

@@ -1,5 +1,6 @@
import { Box, FormControl, InputLabel, MenuItem, Select, TextField } from '@mui/material';
import React from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useLanguage } from '../../contexts/LanguageContext';
interface BasicSettingsProps {
@@ -10,6 +11,7 @@ interface BasicSettingsProps {
const BasicSettings: React.FC<BasicSettingsProps> = ({ language, websiteName, onChange }) => {
const { t } = useLanguage();
const { userRole } = useAuth();
return (
<Box>
@@ -36,21 +38,23 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ language, websiteName, on
</Select>
</FormControl>
<TextField
fullWidth
label={t('websiteName')}
value={websiteName || ''}
onChange={(e) => onChange('websiteName', e.target.value)}
placeholder="MyTube"
helperText={t('websiteNameHelper', {
current: (websiteName || '').length,
max: 15,
default: 'MyTube'
})}
slotProps={{ htmlInput: { maxLength: 15 } }}
/>
{userRole !== 'visitor' && (
<TextField
fullWidth
label={t('websiteName')}
value={websiteName || ''}
onChange={(e) => onChange('websiteName', e.target.value)}
placeholder="MyTube"
helperText={t('websiteNameHelper', {
current: (websiteName || '').length,
max: 15,
default: 'MyTube'
})}
slotProps={{ htmlInput: { maxLength: 15 } }}
/>
)}
</Box>
</Box>
</Box >
);
};

View File

@@ -80,7 +80,7 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
console.error('Error creating passkey:', error);
// Extract error message from axios response or error object
let errorMessage = t('passkeyCreationFailed') || 'Failed to create passkey. Please try again.';
if (error?.response?.data?.error) {
// Backend error message
errorMessage = error.response.data.error;
@@ -89,13 +89,13 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
} else if (error?.message) {
errorMessage = error.message;
}
// Check if this is a WebAuthn error that can be translated
const translationKey = getWebAuthnErrorTranslationKey(errorMessage);
if (translationKey) {
errorMessage = t(translationKey) || errorMessage;
}
showAlert(t('error'), errorMessage);
},
});
@@ -153,6 +153,7 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
<Switch
checked={settings.loginEnabled}
onChange={(e) => onChange('loginEnabled', e.target.checked)}
disabled={settings.visitorMode} // Locked enabled if visitor mode is on
/>
}
label={t('enableLogin')}
@@ -193,6 +194,54 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
</Typography>
</Box>
<FormControlLabel
control={
<Switch
checked={settings.visitorMode === true}
onChange={(e) => {
const enabled = e.target.checked;
onChange('visitorMode', enabled);
// Lock loginEnabled to true if visitor mode is enabled
if (enabled) {
if (!settings.loginEnabled) {
onChange('loginEnabled', true);
}
if (!settings.visitorPassword) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
const array = new Uint32Array(12);
window.crypto.getRandomValues(array);
const newPassword = Array.from(array, x => chars[x % chars.length]).join('');
onChange('visitorPassword', newPassword);
}
}
}}
disabled={!settings.loginEnabled && settings.visitorMode} // Unlock only if login is enabled? Actually user said "loginEnabled should be locked enabled". So if visitor mode is ON, loginEnabled switch (above) should be disabled or force checked.
/>
}
label={t('visitorUser') || 'Visitor User'}
/>
<Box sx={{ mt: 1, mb: 2 }}>
<Typography variant="body2" color="text.secondary">
{t('visitorUserHelper') || 'Enable a restricted Visitor User role. Visitors have read-only access and cannot change settings.'}
</Typography>
</Box>
{settings.visitorMode && (
<TextField
fullWidth
sx={{ mb: 2 }}
label={t('visitorPassword') || 'Visitor Password'}
type="text" // User said "It should be visible" - wait, "show a input ... It should be visible". Does it mean the input is visible, or the password text is visible? "let admin setup visior password. It should be visible." Usually setup inputs are passwords but maybe they want it visible to see what it is? let's stick to type="text" or "password" with show toggle. "It should be visible" likely means the input field itself appears. I will use standard password field for security but maybe default show? Or just text if implied. "It should be visible" logically refers to the input field appearing. Safe bet is standard password field behavior.
value={settings.visitorPassword || ''}
onChange={(e) => onChange('visitorPassword', e.target.value)}
helperText={
settings.isVisitorPasswordSet
? (t('visitorPasswordSetHelper') || 'Password is set. Leave empty to keep it.')
: (t('visitorPasswordHelper') || 'Password for the Visitor User to log in.')
}
/>
)}
<FormControlLabel
control={
<Switch

View File

@@ -1,149 +0,0 @@
import { Box, FormControlLabel, Switch, Typography } from '@mui/material';
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import PasswordModal from '../PasswordModal';
const API_URL = import.meta.env.VITE_API_URL;
interface VisitorModeSettingsProps {
visitorMode?: boolean;
savedVisitorMode?: boolean;
onChange: (field: string, value: string | number | boolean) => void;
}
const VisitorModeSettings: React.FC<VisitorModeSettingsProps> = ({ visitorMode, savedVisitorMode: _savedVisitorMode, onChange }) => {
const { t } = useLanguage();
const queryClient = useQueryClient();
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [passwordError, setPasswordError] = useState('');
const [isVerifyingPassword, setIsVerifyingPassword] = useState(false);
const [pendingVisitorMode, setPendingVisitorMode] = useState<boolean | null>(null);
const [remainingWaitTime, setRemainingWaitTime] = useState(0);
const [baseError, setBaseError] = useState('');
const handleVisitorModeChange = (checked: boolean) => {
setPendingVisitorMode(checked);
setPasswordError('');
setBaseError('');
setRemainingWaitTime(0);
setShowPasswordModal(true);
};
const handlePasswordConfirm = async (password: string) => {
setIsVerifyingPassword(true);
setPasswordError('');
setBaseError('');
try {
await axios.post(`${API_URL}/settings/verify-password`, { password });
// If successful, save the setting immediately
if (pendingVisitorMode !== null) {
// Save to backend
await axios.post(`${API_URL}/settings`, { visitorMode: pendingVisitorMode });
// Invalidate settings query to ensure global state (VisitorModeContext) updates immediately
await queryClient.invalidateQueries({ queryKey: ['settings'] });
// Update parent state
onChange('visitorMode', pendingVisitorMode);
}
setShowPasswordModal(false);
setPendingVisitorMode(null);
} catch (error: any) {
console.error('Password verification failed:', error);
if (error.response) {
const { status, data } = error.response;
if (status === 429) {
const waitTimeMs = data.waitTime || 0;
const seconds = Math.ceil(waitTimeMs / 1000);
setRemainingWaitTime(seconds);
setBaseError(t('tooManyAttempts') || 'Too many attempts.');
} else if (status === 401) {
const waitTimeMs = data.waitTime || 0;
if (waitTimeMs > 0) {
const seconds = Math.ceil(waitTimeMs / 1000);
setRemainingWaitTime(seconds);
setBaseError(t('incorrectPassword') || 'Incorrect password.');
} else {
setPasswordError(t('incorrectPassword') || 'Incorrect password');
}
} else {
setPasswordError(t('loginFailed') || 'Verification failed');
}
} else {
setPasswordError(t('networkError' as any) || 'Network error');
}
} finally {
setIsVerifyingPassword(false);
}
};
const handleClosePasswordModal = () => {
setShowPasswordModal(false);
setPendingVisitorMode(null);
setPasswordError('');
setBaseError('');
setRemainingWaitTime(0);
};
// Effect to handle countdown
useEffect(() => {
let interval: NodeJS.Timeout;
if (remainingWaitTime > 0) {
// Update error message immediately
const waitMsg = t('waitTimeMessage')?.replace('{time}', `${remainingWaitTime}s`) || `Please wait ${remainingWaitTime}s.`;
setPasswordError(`${baseError} ${waitMsg}`);
interval = setInterval(() => {
setRemainingWaitTime((prev) => {
if (prev <= 1) {
// Countdown finished
setPasswordError(baseError);
return 0;
}
return prev - 1;
});
}, 1000);
}
return () => {
if (interval) clearInterval(interval);
};
}, [remainingWaitTime, baseError, t]);
return (
<Box>
<Box>
<FormControlLabel
control={
<Switch
checked={visitorMode ?? false}
onChange={(e) => handleVisitorModeChange(e.target.checked)}
/>
}
label={t('visitorMode') || "Visitor Mode (Read-only)"}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, ml: 4.5 }}>
{t('visitorModeDescription') || "Read-only mode. Hidden videos will not be visible to visitors."}
</Typography>
</Box>
<PasswordModal
isOpen={showPasswordModal}
onClose={handleClosePasswordModal}
onConfirm={handlePasswordConfirm}
title={t('password' as any) || "Enter Website Password"}
message={t('visitorModePasswordPrompt' as any) || "Please enter the website password to change Visitor Mode settings."}
error={passwordError}
isLoading={isVerifyingPassword}
/>
</Box>
);
};
export default VisitorModeSettings;

View File

@@ -32,6 +32,8 @@ describe('SecuritySettings', () => {
loginEnabled: false,
password: '',
isPasswordSet: false,
visitorMode: false,
visitorPassword: '',
};
beforeEach(() => {
@@ -42,6 +44,7 @@ describe('SecuritySettings', () => {
render(<SecuritySettings settings={defaultSettings} onChange={mockOnChange} />);
expect(screen.getByLabelText('enableLogin')).toBeInTheDocument();
expect(screen.queryByLabelText('visitorUser')).not.toBeInTheDocument();
expect(screen.queryByLabelText('password')).not.toBeInTheDocument();
});
@@ -49,9 +52,17 @@ describe('SecuritySettings', () => {
render(<SecuritySettings settings={{ ...defaultSettings, loginEnabled: true }} onChange={mockOnChange} />);
expect(screen.getByLabelText('password')).toBeInTheDocument();
expect(screen.getByLabelText('visitorUser')).toBeInTheDocument();
expect(screen.getByText('passwordSetHelper')).toBeInTheDocument();
});
it('should show visitor password field when visitor mode is enabled', () => {
render(<SecuritySettings settings={{ ...defaultSettings, loginEnabled: true, visitorMode: true }} onChange={mockOnChange} />);
expect(screen.getByLabelText('visitorPassword')).toBeInTheDocument();
expect(screen.getByText('visitorPasswordHelper')).toBeInTheDocument();
});
it('should handle switch change', async () => {
const user = userEvent.setup();
render(<SecuritySettings settings={defaultSettings} onChange={mockOnChange} />);
@@ -60,6 +71,14 @@ describe('SecuritySettings', () => {
expect(mockOnChange).toHaveBeenCalledWith('loginEnabled', true);
});
it('should handle visitor switch change', async () => {
const user = userEvent.setup();
render(<SecuritySettings settings={{ ...defaultSettings, loginEnabled: true }} onChange={mockOnChange} />);
await user.click(screen.getByLabelText('visitorUser'));
expect(mockOnChange).toHaveBeenCalledWith('visitorMode', true);
});
it('should handle password change', async () => {
const user = userEvent.setup();
render(<SecuritySettings settings={{ ...defaultSettings, loginEnabled: true }} onChange={mockOnChange} />);
@@ -68,4 +87,20 @@ describe('SecuritySettings', () => {
await user.type(input, 'secret');
expect(mockOnChange).toHaveBeenCalledWith('password', 's');
});
it('should handle visitor password change', async () => {
const user = userEvent.setup();
render(<SecuritySettings settings={{ ...defaultSettings, loginEnabled: true, visitorMode: true }} onChange={mockOnChange} />);
const input = screen.getByLabelText('visitorPassword');
await user.type(input, 'guest');
expect(mockOnChange).toHaveBeenCalledWith('visitorPassword', 'g');
});
it('should disable login enabled switch when visitor mode is enabled', () => {
render(<SecuritySettings settings={{ ...defaultSettings, visitorMode: true, loginEnabled: true }} onChange={mockOnChange} />);
const loginSwitch = screen.getByLabelText('enableLogin');
expect(loginSwitch).toBeDisabled();
});
});

View File

@@ -8,7 +8,8 @@ interface AuthContextType {
isAuthenticated: boolean;
loginRequired: boolean;
checkingAuth: boolean;
login: () => void;
userRole: 'admin' | 'visitor' | null;
login: (token?: string, role?: 'admin' | 'visitor') => void;
logout: () => void;
}
@@ -16,6 +17,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [userRole, setUserRole] = useState<'admin' | 'visitor' | null>(null);
const [loginRequired, setLoginRequired] = useState<boolean>(true); // Assume required until checked
const queryClient = useQueryClient();
@@ -38,8 +40,19 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const sessionAuth = sessionStorage.getItem('mytube_authenticated');
if (sessionAuth === 'true') {
setIsAuthenticated(true);
// Restore role from session storage
const storedRole = sessionStorage.getItem('mytube_role');
if (storedRole === 'admin' || storedRole === 'visitor') {
setUserRole(storedRole);
}
// Restore token header
const token = sessionStorage.getItem('mytube_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
} else {
setIsAuthenticated(false);
setUserRole(null);
}
}
return response.data;
@@ -50,19 +63,33 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
});
const login = () => {
const login = (token?: string, role?: 'admin' | 'visitor') => {
setIsAuthenticated(true);
sessionStorage.setItem('mytube_authenticated', 'true');
if (token) {
sessionStorage.setItem('mytube_token', token);
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
if (role) {
setUserRole(role);
sessionStorage.setItem('mytube_role', role);
}
};
const logout = () => {
setIsAuthenticated(false);
setUserRole(null);
sessionStorage.removeItem('mytube_authenticated');
sessionStorage.removeItem('mytube_token');
sessionStorage.removeItem('mytube_role');
delete axios.defaults.headers.common['Authorization'];
queryClient.invalidateQueries({ queryKey: ['authSettings'] });
};
return (
<AuthContext.Provider value={{ isAuthenticated, loginRequired, checkingAuth, login, logout }}>
<AuthContext.Provider value={{ isAuthenticated, loginRequired, checkingAuth, userRole, login, logout }}>
{children}
</AuthContext.Provider>
);

View File

@@ -1,9 +1,12 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import axios from 'axios';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import React, { createContext, ReactNode, useContext } from 'react';
import { useAuth } from './AuthContext';
const API_URL = import.meta.env.VITE_API_URL;
interface VisitorModeContextType {
visitorMode: boolean;
isLoading: boolean;
@@ -14,7 +17,9 @@ const VisitorModeContext = createContext<VisitorModeContextType>({
isLoading: true,
});
export const VisitorModeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { userRole } = useAuth();
const { data: settingsData, isLoading } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
@@ -26,7 +31,8 @@ export const VisitorModeProvider: React.FC<{ children: ReactNode }> = ({ childre
gcTime: 10 * 60 * 1000, // Garbage collect after 10 minutes
});
const visitorMode = settingsData?.visitorMode === true;
// Visitor mode is active if enabled in settings AND user is not an admin
const visitorMode = settingsData?.visitorMode === true && userRole !== 'admin';
return (
<VisitorModeContext.Provider value={{ visitorMode, isLoading }}>

View File

@@ -15,7 +15,7 @@ const TestComponent = () => {
<div>
<div data-testid="auth-status">{isAuthenticated ? 'Authenticated' : 'Not Authenticated'}</div>
<div data-testid="login-required">{loginRequired ? 'Required' : 'Optional'}</div>
<button onClick={login}>Login</button>
<button onClick={() => login('mock-token')}>Login</button>
<button onClick={logout}>Logout</button>
</div>
);

View File

@@ -1,6 +1,7 @@
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { useEffect, useRef, useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { Video } from '../types';
const API_URL = import.meta.env.VITE_API_URL;
@@ -14,6 +15,7 @@ interface UseVideoProgressProps {
* Custom hook to manage video progress tracking and view counting
*/
export function useVideoProgress({ videoId, video }: UseVideoProgressProps) {
const { userRole } = useAuth();
const queryClient = useQueryClient();
const [hasViewed, setHasViewed] = useState<boolean>(false);
const lastProgressSave = useRef<number>(0);
@@ -29,20 +31,20 @@ export function useVideoProgress({ videoId, video }: UseVideoProgressProps) {
// Save progress on unmount
useEffect(() => {
return () => {
if (videoId && currentTimeRef.current > 0 && !isDeletingRef.current) {
if (videoId && currentTimeRef.current > 0 && !isDeletingRef.current && userRole !== 'visitor') {
axios.put(`${API_URL}/videos/${videoId}/progress`, {
progress: Math.floor(currentTimeRef.current)
})
.catch(err => console.error('Error saving progress on unmount:', err));
}
};
}, [videoId]);
}, [videoId, userRole]);
const handleTimeUpdate = (currentTime: number) => {
currentTimeRef.current = currentTime;
// Increment view count after 10 seconds
if (currentTime > 10 && !hasViewed && videoId) {
if (currentTime > 10 && !hasViewed && videoId && userRole !== 'visitor') {
setHasViewed(true);
axios.post(`${API_URL}/videos/${videoId}/view`)
.then(res => {
@@ -57,7 +59,7 @@ export function useVideoProgress({ videoId, video }: UseVideoProgressProps) {
// Save progress every 5 seconds
const now = Date.now();
if (now - lastProgressSave.current > 5000 && videoId) {
if (now - lastProgressSave.current > 5000 && videoId && userRole !== 'visitor') {
lastProgressSave.current = now;
axios.put(`${API_URL}/videos/${videoId}/progress`, {
progress: Math.floor(currentTime)

View File

@@ -10,6 +10,8 @@ import {
Divider,
IconButton,
InputAdornment,
Tab,
Tabs,
TextField,
ThemeProvider,
Tooltip,
@@ -31,10 +33,13 @@ import { getWebAuthnErrorTranslationKey } from '../utils/translations';
const API_URL = import.meta.env.VITE_API_URL;
const LoginPage: React.FC = () => {
const [visitorPassword, setVisitorPassword] = useState('');
const [showVisitorPassword, setShowVisitorPassword] = useState(false);
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [waitTime, setWaitTime] = useState(0); // in milliseconds
const [activeTab, setActiveTab] = useState(0); // 0 = Admin, 1 = Visitor
const [showResetModal, setShowResetModal] = useState(false);
const [alertOpen, setAlertOpen] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
@@ -190,29 +195,27 @@ const LoginPage: React.FC = () => {
};
const loginMutation = useMutation({
mutationFn: async (password: string) => {
const response = await axios.post(`${API_URL}/settings/verify-password`, { password });
mutationFn: async (passwordToVerify: string) => {
const response = await axios.post(`${API_URL}/settings/verify-password`, { password: passwordToVerify });
return response.data;
},
onSuccess: (data) => {
if (data.success) {
setWaitTime(0); // Reset wait time on success
login();
login(data.token, data.role);
} else {
showAlert(t('error'), t('incorrectPassword'));
}
},
onError: (err: any) => {
console.error('Login error:', err);
if (err.response) {
const responseData = err.response.data;
if (err.response.status === 429) {
// Handle failures (incorrect password or too many attempts)
// These are returned as 200 OK with success: false to avoid console errors
const statusCode = data.statusCode || 401;
const responseData = data;
if (statusCode === 429) {
// Too many attempts - wait time required
const waitTimeMs = responseData.waitTime || 0;
setWaitTime(waitTimeMs);
const formattedTime = formatWaitTime(waitTimeMs);
showAlert(t('error'), `${t('tooManyAttempts')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`);
} else if (err.response.status === 401) {
} else if (statusCode === 401) {
// Incorrect password - check if wait time is returned
const waitTimeMs = responseData.waitTime || 0;
if (waitTimeMs > 0) {
@@ -225,12 +228,28 @@ const LoginPage: React.FC = () => {
} else {
showAlert(t('error'), t('loginFailed'));
}
} else {
showAlert(t('error'), t('loginFailed'));
}
},
onError: (err: any) => {
console.error('Login error:', err);
// Handle actual network errors or unexpected 500s
showAlert(t('error'), t('loginFailed'));
}
});
// ...
const handleVisitorSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (waitTime > 0) {
return;
}
setError('');
loginMutation.mutate(visitorPassword);
}
const resetPasswordMutation = useMutation({
mutationFn: async () => {
const response = await axios.post(`${API_URL}/settings/reset-password`);
@@ -283,16 +302,20 @@ const LoginPage: React.FC = () => {
return verifyResponse.data;
},
onSuccess: () => {
onSuccess: (data) => {
setError('');
setWaitTime(0);
login();
if (data.token && data.role) {
login(data.token, data.role);
} else {
login(); // Fallback if no token returned (shouldn't happen with new backend)
}
},
onError: (err: any) => {
console.error('Passkey login error:', err);
// Extract error message from axios response or error object
let errorMessage = t('passkeyLoginFailed') || 'Passkey authentication failed. Please try again.';
if (err?.response?.data?.error) {
// Backend error message (e.g., "No passkeys registered" or "No passkeys found for RP_ID")
errorMessage = err.response.data.error;
@@ -301,13 +324,13 @@ const LoginPage: React.FC = () => {
} else if (err?.message) {
errorMessage = err.message;
}
// Check if this is a WebAuthn error that can be translated
const translationKey = getWebAuthnErrorTranslationKey(errorMessage);
if (translationKey) {
errorMessage = t(translationKey) || errorMessage;
}
showAlert(t('error'), errorMessage);
}
});
@@ -418,114 +441,191 @@ const LoginPage: React.FC = () => {
{t('signIn')}
</Typography>
</Box>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1, width: '100%' }}>
{passwordLoginAllowed && (
<>
<TextField
margin="normal"
required
fullWidth
name="password"
label={t('password')}
type={showPassword ? 'text' : 'password'}
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
disabled={waitTime > 0 || loginMutation.isPending}
helperText={t('defaultPasswordHint') || "Default password: 123"}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t('togglePasswordVisibility')}
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loginMutation.isPending || waitTime > 0}
>
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : t('signIn')}
</Button>
{passkeysExist && !visitorMode && (
<>
<Divider sx={{ my: 2 }}>OR</Divider>
<Box sx={{ mt: 1, width: '100%' }}>
{visitorMode && (
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={activeTab} onChange={(_: React.SyntheticEvent, newValue: number) => setActiveTab(newValue)} aria-label="login tabs" variant="fullWidth">
<Tab label={t('admin') || 'Admin'} id="login-tab-0" aria-controls="login-tabpanel-0" />
<Tab label={t('visitorUser') || 'Visitor'} id="login-tab-1" aria-controls="login-tabpanel-1" />
</Tabs>
</Box>
)}
{/* Admin Tab Panel (and default view when visitor mode is off) */}
<div
role="tabpanel"
hidden={visitorMode && activeTab !== 0}
id="login-tabpanel-0"
aria-labelledby="login-tab-0"
>
{(visitorMode ? activeTab === 0 : true) && (
<>
{passwordLoginAllowed && (
<Box component="form" onSubmit={handleSubmit} noValidate>
<TextField
margin="normal"
required
fullWidth
name="password"
label={t('password') || 'Admin Password'}
type={showPassword ? 'text' : 'password'}
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus={!visitorMode || activeTab === 0}
disabled={waitTime > 0 || loginMutation.isPending}
helperText={t('defaultPasswordHint') || "Default password: 123"}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t('togglePasswordVisibility')}
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loginMutation.isPending || waitTime > 0}
>
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('signIn') || 'Admin Sign In')}
</Button>
</Box>
)}
{passkeysExist && (
<>
<Divider sx={{ my: 2 }}>OR</Divider>
<Button
fullWidth
variant="outlined"
startIcon={<Fingerprint />}
onClick={handlePasskeyLogin}
sx={{ mb: 2 }}
disabled={passkeyLoginMutation.isPending || waitTime > 0}
>
{passkeyLoginMutation.isPending
? (t('authenticating') || 'Authenticating...')
: (t('loginWithPasskey') || 'Login with Passkey')}
</Button>
</>
)}
{!passwordLoginAllowed && passkeysExist && (
<Button
fullWidth
variant="outlined"
variant="contained"
startIcon={<Fingerprint />}
onClick={handlePasskeyLogin}
sx={{ mb: 2 }}
sx={{ mt: 3, mb: 2 }}
disabled={passkeyLoginMutation.isPending || waitTime > 0}
>
{passkeyLoginMutation.isPending
? (t('authenticating') || 'Authenticating...')
: (t('loginWithPasskey') || 'Login with Passkey')}
</Button>
</>
)}
{allowResetPassword && (
<Button
fullWidth
variant="outlined"
startIcon={<Refresh />}
onClick={() => setShowResetModal(true)}
sx={{ mb: 2 }}
disabled={resetPasswordMutation.isPending || resetPasswordCooldown > 0}
>
{resetPasswordCooldown > 0
? `${t('resetPassword')} (${formatWaitTime(resetPasswordCooldown)})`
: t('resetPassword')}
</Button>
)}
{!allowResetPassword && passwordLoginAllowed && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 2 }}>
<Tooltip title={t('resetPasswordDisabledInfo') || 'Click for information about resetting password'}>
<IconButton
onClick={() => showAlert(
t('resetPassword') || 'Reset Password',
t('resetPasswordDisabledInfo') || 'Password reset is disabled. To reset your password, run the following command in the backend directory:\n\nnpm run reset-password\n\nOr:\n\nts-node scripts/reset-password.ts\n\nThis will generate a new random password and enable password login.'
)}
color="primary"
sx={{
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
<InfoOutlined />
</IconButton>
</Tooltip>
</Box>
)}
</>
)}
</div>
{/* Visitor Tab Panel */}
{visitorMode && (
<div
role="tabpanel"
hidden={activeTab !== 1}
id="login-tabpanel-1"
aria-labelledby="login-tab-1"
>
{activeTab === 1 && (
<Box component="form" onSubmit={handleVisitorSubmit} noValidate>
<TextField
margin="normal"
required
fullWidth
name="visitorPassword"
label={t('visitorPassword') || 'Visitor Password'}
type={showVisitorPassword ? 'text' : 'password'}
id="visitorPassword"
value={visitorPassword}
onChange={(e) => setVisitorPassword(e.target.value)}
autoFocus={activeTab === 1}
disabled={waitTime > 0 || loginMutation.isPending}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t('togglePasswordVisibility')}
onClick={() => setShowVisitorPassword(!showVisitorPassword)}
edge="end"
>
{showVisitorPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loginMutation.isPending || waitTime > 0}
>
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('visitorSignIn') || 'Visitor Sign In')}
</Button>
</Box>
)}
</>
)}
{!passwordLoginAllowed && passkeysExist && !visitorMode && (
<Button
fullWidth
variant="contained"
startIcon={<Fingerprint />}
onClick={handlePasskeyLogin}
sx={{ mt: 3, mb: 2 }}
disabled={passkeyLoginMutation.isPending || waitTime > 0}
>
{passkeyLoginMutation.isPending
? (t('authenticating') || 'Authenticating...')
: (t('loginWithPasskey') || 'Login with Passkey')}
</Button>
)}
{allowResetPassword && !visitorMode && (
<Button
fullWidth
variant="outlined"
startIcon={<Refresh />}
onClick={() => setShowResetModal(true)}
sx={{ mb: 2 }}
disabled={resetPasswordMutation.isPending || resetPasswordCooldown > 0}
>
{resetPasswordCooldown > 0
? `${t('resetPassword')} (${formatWaitTime(resetPasswordCooldown)})`
: t('resetPassword')}
</Button>
)}
{!allowResetPassword && passwordLoginAllowed && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 2 }}>
<Tooltip title={t('resetPasswordDisabledInfo') || 'Click for information about resetting password'}>
<IconButton
onClick={() => showAlert(
t('resetPassword') || 'Reset Password',
t('resetPasswordDisabledInfo') || 'Password reset is disabled. To reset your password, run the following command in the backend directory:\n\nnpm run reset-password\n\nOr:\n\nts-node scripts/reset-password.ts\n\nThis will generate a new random password and enable password login.'
)}
color="primary"
sx={{
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
<InfoOutlined />
</IconButton>
</Tooltip>
</Box>
</div>
)}
<Box sx={{ minHeight: waitTime > 0 || (error && waitTime === 0) ? 'auto' : 0, mt: 2 }}>
{waitTime > 0 && (

View File

@@ -27,8 +27,8 @@ import InterfaceDisplaySettings from '../components/Settings/InterfaceDisplaySet
import SecuritySettings from '../components/Settings/SecuritySettings';
import TagsSettings from '../components/Settings/TagsSettings';
import VideoDefaultSettings from '../components/Settings/VideoDefaultSettings';
import VisitorModeSettings from '../components/Settings/VisitorModeSettings';
import YtDlpSettings from '../components/Settings/YtDlpSettings';
import { useAuth } from '../contexts/AuthContext';
import { useDownload } from '../contexts/DownloadContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useVisitorMode } from '../contexts/VisitorModeContext';
@@ -46,6 +46,7 @@ const SettingsPage: React.FC = () => {
const { t, setLanguage } = useLanguage();
const { activeDownloads } = useDownload();
const { visitorMode } = useVisitorMode();
const { userRole } = useAuth();
const [settings, setSettings] = useState<Settings>({
loginEnabled: false,
@@ -198,43 +199,34 @@ const SettingsPage: React.FC = () => {
)}
{/* 3. Security & Access */}
<Grid size={12}>
<CollapsibleSection title={t('securityAccess')} defaultExpanded={false}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<VisitorModeSettings
visitorMode={settings.visitorMode}
savedVisitorMode={settingsData?.visitorMode}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
{!visitorMode && userRole !== 'visitor' && (
<Grid size={12}>
<CollapsibleSection title={t('securityAccess')} defaultExpanded={false}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<SecuritySettings
settings={settings}
onChange={handleChange}
/>
</Box>
<Box>
<CookieSettings
onSuccess={(msg) => setMessage({ text: msg, type: 'success' })}
onError={(msg) => setMessage({ text: msg, type: 'error' })}
/>
</Box>
<Box>
<CloudflareSettings
enabled={settings.cloudflaredTunnelEnabled}
token={settings.cloudflaredToken}
visitorMode={visitorMode}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
</Box>
</Box>
{!visitorMode && (
<>
<Box>
<SecuritySettings
settings={settings}
onChange={handleChange}
/>
</Box>
<Box>
<CookieSettings
onSuccess={(msg) => setMessage({ text: msg, type: 'success' })}
onError={(msg) => setMessage({ text: msg, type: 'error' })}
/>
</Box>
</>
)}
<Box>
<CloudflareSettings
enabled={settings.cloudflaredTunnelEnabled}
token={settings.cloudflaredToken}
visitorMode={visitorMode}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
</Box>
</Box>
</CollapsibleSection>
</Grid>
</CollapsibleSection>
</Grid>
)}
{!visitorMode && (
<>

View File

@@ -61,6 +61,7 @@ export interface Settings {
isPasswordSet?: boolean;
passwordLoginAllowed?: boolean;
allowResetPassword?: boolean;
isVisitorPasswordSet?: boolean;
defaultAutoPlay: boolean;
defaultAutoLoop: boolean;
maxConcurrentDownloads: number;
@@ -82,6 +83,7 @@ export interface Settings {
moveSubtitlesToVideoFolder?: boolean;
moveThumbnailsToVideoFolder?: boolean;
visitorMode?: boolean;
visitorPassword?: string;
infiniteScroll?: boolean;
videoColumns?: number;
cloudflaredTunnelEnabled?: boolean;

View File

@@ -23,6 +23,10 @@ const apiClient: AxiosInstance = axios.create({
apiClient.interceptors.request.use(
(config) => {
// Add any request modifications here (e.g., auth tokens)
const token = sessionStorage.getItem('mytube_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
@@ -187,7 +191,7 @@ export { apiClient };
/**
* Export API_URL for cases where it's needed directly
*/
export { API_URL };
export { API_URL };
export default api;

View File

@@ -687,4 +687,14 @@ export const ar = {
disclaimerTitle: "إخلاء المسؤولية",
disclaimerText:
"1. الغرض والقيود\nهذا البرنامج (بما في ذلك الكود والوثائق) مخصص فقط للتعلم الشخصي والبحث والتبادل التقني. يُحظر تمامًا استخدام هذا البرنامج لأي أغراض تجارية أو لأي أنشطة غير قانونية تنتهك القوانين واللوائح المحلية.\n\n2. المسؤولية\nالمطور ليس على علم ولا يملك أي سيطرة على كيفية استخدام المستخدمين لهذا البرنامج. يتحمل المستخدم وحده أي مسؤوليات قانونية أو نزاعات أو أضرار تنشأ عن الاستخدام غير القانوني أو غير السليم لهذا البرنامج (بما في ذلك على سبيل المثال لا الحصر انتهاك حقوق الطبع والنشر). لا يتحمل المطور أي مسؤولية مباشرة أو غير مباشرة أو مشتركة.\n\n3. التعديلات والتوزيع\nهذا المشروع مفتوح المصدر. يجب على أي فرد أو منظمة تقوم بتعديل أو تفرع هذا الكود الالتزام بترخيص المصدر المفتوح. هام: إذا قام طرف ثالث بتعديل الكود لتجاوز أو إزالة آليات مصادقة/أمان المستخدم الأصلية وتوزيع مثل هذه الإصدارات، فإن المعدل/الموزع يتحمل المسؤولية الكاملة عن أي عواقب. ننصح بشدة بعدم تجاوز أو العبث بأي آليات للتحقق من الأمان.\n\n4. بيان غير ربحي\nهذا مشروع مفتوح المصدر مجاني تمامًا. لا يقبل المطور التبرعات ولم ينشر أي صفحات للتبرع. لا يسمح البرنامج نفسه بأي رسوم ولا يقدم أي خدمات مدفوعة. يرجى توخي الحذر والحذر من أي عمليات احتيال أو معلومات مضللة تدعي تحصيل رسوم نيابة عن هذا المشروع.",
// Visitor Mode
admin: "مشرف",
visitorSignIn: "تسجيل دخول الزائر",
visitorUser: "المستخدم الزائر",
visitorUserHelper:
"قم تمكين حساب زائر منفصل مع وصول للقراءة فقط. يمكن للزوار عرض المحتوى ولكن لا يمكنهم إجراء تغييرات.",
visitorPassword: "كلمة مرور الزائر",
visitorPasswordHelper: "تعيين كلمة المرور لحساب الزائر.",
visitorPasswordSetHelper:
"تم تعيين كلمة المرور. اتركها فارغة للاحتفاظ بها.",
};

View File

@@ -690,4 +690,14 @@ export const de = {
disclaimerTitle: "Haftungsausschluss",
disclaimerText:
"1. Zweck und Einschränkungen\nDiese Software (einschließlich Code und Dokumentation) ist ausschließlich für persönliches Lernen, Forschung und technischen Austausch bestimmt. Es ist strengstens untersagt, diese Software für kommerzielle Zwecke oder illegale Aktivitäten zu verwenden, die gegen lokale Gesetze und Vorschriften verstoßen.\n\n2. Haftung\nDer Entwickler hat keine Kontrolle darüber, wie Benutzer diese Software verwenden. Jegliche rechtliche Haftung, Streitigkeiten oder Schäden, die aus der illegalen oder unsachgemäßen Verwendung dieser Software entstehen (einschließlich, aber nicht beschränkt auf Urheberrechtsverletzungen), liegen allein beim Benutzer. Der Entwickler übernimmt keine direkte, indirekte oder gesamtschuldnerische Haftung.\n\n3. Änderungen und Verbreitung\nDieses Projekt ist Open Source. Jede Einzelperson oder Organisation, die diesen Code ändert oder forkt, muss die Open-Source-Lizenz einhalten. Wichtig: Wenn Dritte den Code ändern, um die ursprünglichen Benutzerauthentifizierungs-/Sicherheitsmechanismen zu umgehen oder zu entfernen, und solche Versionen verbreiten, trägt der Modifikator/Verteiler die volle Verantwortung für alle Konsequenzen. Wir raten dringend davon ab, Sicherheitsüberprüfungsmechanismen zu umgehen oder zu manipulieren.\n\n4. Gemeinnützige Erklärung\nDies ist ein komplett kostenloses Open-Source-Projekt. Der Entwickler akzeptiert keine Spenden und hat nie Spendenseiten veröffentlicht. Die Software selbst erlaubt keine Gebühren und bietet keine kostenpflichtigen Dienste an. Bitte seien Sie wachsam und hüten Sie sich vor Betrug oder irreführenden Informationen, die behaupten, Gebühren im Namen dieses Projekts zu erheben.",
// Visitor Mode
admin: "Admin",
visitorSignIn: "Besucher-Anmeldung",
visitorUser: "Besucher-Benutzer",
visitorUserHelper:
"Aktivieren Sie ein separates Besucherkonto mit schreibgeschütztem Zugriff. Besucher können Inhalte ansehen, aber keine Änderungen vornehmen.",
visitorPassword: "Besucher-Passwort",
visitorPasswordHelper: "Legen Sie das Passwort für das Besucherkonto fest.",
visitorPasswordSetHelper:
"Passwort ist gesetzt. Leer lassen, um es zu behalten.",
};

View File

@@ -160,6 +160,12 @@ export const en = {
"Read-only mode. Hidden videos will not be visible to visitors.",
visitorModePasswordPrompt:
"Please enter the website password to change Visitor Mode settings.",
visitorUser: "Visitor User",
visitorUserHelper:
"Enable a separate visitor account with read-only access. Visitors can view content but cannot make changes.",
visitorPassword: "Visitor Password",
visitorPasswordHelper: "Set the password for the visitor account.",
visitorPasswordSetHelper: "Password is set. Leave empty to keep it.",
cleanupTempFilesSuccess: "Successfully deleted {count} temporary file(s).",
cleanupTempFilesFailed: "Failed to clean up temporary files",
@@ -320,6 +326,9 @@ export const en = {
// Login
signIn: "Sign in",
admin: "Admin",
visitorSignIn: "Visitor Sign In",
orVisitor: "OR VISITOR",
verifying: "Verifying...",
incorrectPassword: "Incorrect password",
loginFailed: "Failed to verify password",

View File

@@ -688,4 +688,19 @@ export const es = {
disclaimerTitle: "Descargo de responsabilidad",
disclaimerText:
"1. Propósito y Restricciones\nEste software (incluyendo código y documentación) está destinado únicamente para aprendizaje personal, investigación e intercambio técnico. Está estrictamente prohibido utilizar este software para fines comerciales o actividades ilegales que violen las leyes y regulaciones locales.\n\n2. Responsabilidad\nEl desarrollador desconoce y no tiene control sobre cómo los usuarios utilizan este software. Cualquier responsabilidad legal, disputa o daño derivado del uso ilegal o indebido de este software (incluyendo, entre otros, la infracción de derechos de autor) recaerá únicamente en el usuario. El desarrollador no asume ninguna responsabilidad directa, indirecta o conjunta.\n\n3. Modificaciones y Distribución\nEste proyecto es de código abierto. Cualquier individuo u organización que modifique o bifurque este código debe cumplir con la licencia de código abierto. Importante: Si un tercero modifica el código para eludir o eliminar los mecanismos originales de autenticación/seguridad del usuario y distribuye dichas versiones, el modificador/distribuidor asume toda la responsabilidad por cualquier consecuencia. Desaconsejamos encarecidamente eludir o manipular cualquier mecanismo de verificación de seguridad.\n\n4. Declaración Sin Fines de Lucro\nEste es un proyecto de código abierto completamente gratuito. El desarrollador no acepta donaciones y nunca ha publicado páginas de donación. El software en sí no permite cargos y no ofrece servicios pagos. Por favor, esté atento y tenga cuidado con cualquier estafa o información engañosa que reclame cobrar tarifas en nombre de este proyecto.",
enterPasswordToUploadHook:
"Por favor ingrese su contraseña para subir este script de gancho.",
riskCommandDetected:
"Comando de riesgo detectado: {command}. Carga rechazada.",
// Visitor Mode
admin: "Administrador",
visitorSignIn: "Inicio de Sesión de Visitante",
visitorUser: "Usuario Visitante",
visitorUserHelper:
"Habilite una cuenta de visitante separada con acceso de solo lectura. Los visitantes pueden ver el contenido pero no pueden realizar cambios.",
visitorPassword: "Contraseña de Visitante",
visitorPasswordHelper: "Establezca la contraseña para la cuenta de visitante.",
visitorPasswordSetHelper:
"La contraseña está establecida. Déjelo en blanco para mantenerla.",
};

View File

@@ -720,4 +720,14 @@ export const fr = {
disclaimerTitle: "Avis de non-responsabilité",
disclaimerText:
"1. Objectif et Restrictions\nCe logiciel (y compris le code et la documentation) est destiné uniquement à l'apprentissage personnel, à la recherche et à l'échange technique. Il est strictement interdit d'utiliser ce logiciel à des fins commerciales ou pour toute activité illégale violant les lois et réglementations locales.\n\n2. Responsabilité\nLe développeur n'a aucune connaissance et aucun contrôle sur la façon dont les utilisateurs utilisent ce logiciel. Toute responsabilité légale, litige ou dommage découlant de l'utilisation illégale ou inappropriée de ce logiciel (y compris, mais sans s'y limiter, la violation du droit d'auteur) sera à la charge exclusive de l'utilisateur. Le développeur n'assume aucune responsabilité directe, indirecte ou conjointe.\n\n3. Modifications et Distribution\nCe projet est open source. Tout individu ou organisation modifiant ou forkant ce code doit se conformer à la licence open source. Important : Si un tiers modifie le code pour contourner ou supprimer les mécanismes d'authentification/sécurité d'origine de l'utilisateur et distribue de telles versions, le modificateur/distributeur porte l'entière responsabilité de toutes les conséquences. Nous déconseillons fortement de contourner ou d'altérer tout mécanisme de vérification de sécurité.\n\n4. Déclaration à But Non Lucratif\nCeci est un projet open source entièrement gratuit. Le développeur n'accepte pas de dons et n'a jamais publié de pages de dons. Le logiciel lui-même ne permet aucun frais et n'offre aucun service payant. Veuillez être vigilant et vous méfier de toute arnaque ou information trompeuse prétendant percevoir des frais au nom de ce projet.",
// Visitor Mode
admin: "Administrateur",
visitorSignIn: "Connexion Visiteur",
visitorUser: "Utilisateur Visiteur",
visitorUserHelper:
"Activez un compte visiteur séparé avec un accès en lecture seule. Les visiteurs peuvent voir le contenu mais ne peuvent pas effectuer de modifications.",
visitorPassword: "Mot de passe Visiteur",
visitorPasswordHelper: "Définissez le mot de passe pour le compte visiteur.",
visitorPasswordSetHelper:
"Le mot de passe est défini. Laisser vide pour le conserver.",
};

View File

@@ -707,4 +707,14 @@ export const ja = {
disclaimerTitle: "免責事項",
disclaimerText:
"1. 目的と制限\nこのソフトウェアコードおよびドキュメントを含むは、個人の学習、研究、および技術交流のみを目的としています。このソフトウェアを商業目的で使用すること、または地域の法律や規制に違反する違法行為に使用することは固く禁じられています。\n\n2. 責任\n開発者は、ユーザーがこのソフトウェアをどのように使用するかについて認識しておらず、管理もしていません。このソフトウェアの違法または不適切な使用著作権侵害を含むがこれに限定されないから生じる法的責任、紛争、または損害は、ユーザーのみが負担するものとします。開発者は、直接的、間接的、または連帯責任を負いません。\n\n3. 修正と配布\nこのプロジェクトはオープンソースです。このコードを修正またはフォークする個人または組織は、オープンソースライセンスを遵守する必要があります。重要第三者が元のユーザー認証/セキュリティメカニズムを回避または削除するためにコードを修正し、そのようなバージョンを配布する場合、修正者/配布者はすべての結果に対して全責任を負います。セキュリティ検証メカニズムを回避または改ざんすることを強くお勧めしません。\n\n4. 非営利声明\nこれは完全に無料のオープンソースプロジェクトです。開発者は寄付を受け付けておらず、寄付ページを公開したこともありません。ソフトウェア自体は料金を許可しておらず、有料サービスも提供していません。このプロジェクトに代わって料金を徴収すると主張する詐欺や誤解を招く情報には十分ご注意ください。",
// Visitor Mode
admin: "管理者",
visitorSignIn: "ビジターログイン",
visitorUser: "ビジターユーザー",
visitorUserHelper:
"読み取り専用アクセス権を持つ別のビジターアカウントを有効にします。ビジターはコンテンツを閲覧できますが、変更することはできません。",
visitorPassword: "ビジターパスワード",
visitorPasswordHelper: "ビジターアカウントのパスワードを設定します。",
visitorPasswordSetHelper:
"パスワードが設定されています。変更しない場合は空欄にしてください。",
};

View File

@@ -688,4 +688,13 @@ export const ko = {
disclaimerTitle: "면책 조항",
disclaimerText:
"1. 목적 및 제한\n이 소프트웨어(코드 및 문서 포함)는 개인적인 학습, 연구 및 기술 교류만을 목적으로 합니다. 이 소프트웨어를 상업적 목적으로 사용하거나 현지 법률 및 규정을 위반하는 불법 활동에 사용하는 것은 엄격히 금지됩니다.\n\n2. 책임\n개발자는 사용자가 이 소프트웨어를 어떻게 사용하는지 알지 못하며 통제할 수 없습니다. 이 소프트웨어의 불법적 또는 부적절한 사용(저작권 침해를 포함하되 이에 국한되지 않음)으로 인해 발생하는 모든 법적 책임, 분쟁 또는 손해는 전적으로 사용자가 부담해야 합니다. 개발자는 어떠한 직접적, 간접적 또는 공동 책임도 지지 않습니다.\n\n3. 수정 및 배포\n이 프로젝트는 오픈 소스입니다. 이 코드를 수정하거나 포크하는 개인이나 조직은 오픈 소스 라이선스를 준수해야 합니다. 중요: 제3자가 원래의 사용자 인증/보안 메커니즘을 우회하거나 제거하기 위해 코드를 수정하고 이러한 버전을 배포하는 경우, 수정자/배포자는 모든 결과에 대해 전적인 책임을 집니다. 보안 검증 메커니즘을 우회하거나 변조하는 것을 강력히 권장하지 않습니다.\n\n4. 비영리 성명\n이것은 완전히 무료인 오픈 소스 프로젝트입니다. 개발자는 기부를 받지 않으며 기부 페이지를 게시한 적이 없습니다. 소프트웨어 자체는 요금을 부과하지 않으며 유료 서비스를 제공하지 않습니다. 이 프로젝트를 대신하여 수수료를 징수한다고 주장하는 사기나 오해의 소지가 있는 정보에 주의하시기 바랍니다.",
// Visitor Mode
admin: "관리자",
visitorSignIn: "방문자 로그인",
visitorUser: "방문자 사용자",
visitorUserHelper:
"읽기 전용 액세스 권한이 있는 별도의 방문자 계정을 활성화합니다. 방문자는 콘텐츠를 볼 수 있지만 변경할 수는 없습니다.",
visitorPassword: "방문자 비밀번호",
visitorPasswordHelper: "방문자 계정의 비밀번호를 설정합니다.",
visitorPasswordSetHelper: "비밀번호가 설정되었습니다. 유지하려면 비워 두세요.",
};

View File

@@ -708,4 +708,14 @@ export const pt = {
disclaimerTitle: "Isenção de responsabilidade",
disclaimerText:
"1. Objetivo e Restrições\nEste software (incluindo código e documentação) destina-se exclusivamente a aprendizagem pessoal, pesquisa e intercâmbio técnico. É estritamente proibido usar este software para fins comerciais ou para quaisquer atividades ilegais que violem as leis e regulamentos locais.\n\n2. Responsabilidade\nO desenvolvedor desconhece e não tem controle sobre como os usuários utilizam este software. Quaisquer responsabilidades legais, disputas ou danos decorrentes do uso ilegal ou impróprio deste software (incluindo, mas não se limitando a violação de direitos autorais) serão de responsabilidade exclusiva do usuário. O desenvolvedor não assume nenhuma responsabilidade direta, indireta ou conjunta.\n\n3. Modificações e Distribuição\nEste projeto é de código aberto. Qualquer indivíduo ou organização que modifique ou faça fork deste código deve cumprir a licença de código aberto. Importante: Se um terceiro modificar o código para contornar ou remover os mecanismos originais de autenticação/segurança do usuário e distribuir tais versões, o modificador/distribuidor assume total responsabilidade por quaisquer consequências. Desaconselhamos fortemente contornar ou adulterar quaisquer mecanismos de verificação de segurança.\n\n4. Declaração Sem Fins Lucrativos\nEste é um projeto de código aberto totalmente gratuito. O desenvolvedor não aceita doações e nunca publicou páginas de doação. O software em si não permite cobranças e não oferece serviços pagos. Por favor, esteja vigilante e cuidado com quaisquer golpes ou informações enganosas que aleguem cobrar taxas em nome deste projeto.",
// Visitor Mode
admin: "Admin",
visitorSignIn: "Login de Visitante",
visitorUser: "Usuário Visitante",
visitorUserHelper:
"Habilite uma conta de visitante separada com acesso somente leitura. Os visitantes podem visualizar o conteúdo, mas não podem fazer alterações.",
visitorPassword: "Senha de Visitante",
visitorPasswordHelper: "Defina a senha para a conta de visitante.",
visitorPasswordSetHelper:
"A senha está definida. Deixe em branco para mantê-la.",
};

View File

@@ -702,4 +702,14 @@ export const ru = {
disclaimerTitle: "Отказ от ответственности",
disclaimerText:
"1. Цель и Ограничения\nЭто программное обеспечение (включая код и документацию) предназначено исключительно для личного обучения, исследований и технического обмена. Строго запрещено использовать это программное обеспечение в коммерческих целях или для любой незаконной деятельности, нарушающей местные законы и правила.\n\n2. Ответственность\nРазработчик не знает и не контролирует, как пользователи используют это программное обеспечение. Любая юридическая ответственность, споры или ущерб, возникающие в результате незаконного или ненадлежащего использования этого программного обеспечения (включая, помимо прочего, нарушение авторских прав), возлагаются исключительно на пользователя. Разработчик не несет никакой прямой, косвенной или солидарной ответственности.\n\n3. Модификации и Распространение\nЭтот проект с открытым исходным кодом. Любое физическое лицо или организация, изменяющая или создающая форк этого кода, должна соблюдать лицензию с открытым исходным кодом. Важно: Если третья сторона изменяет код для обхода или удаления оригинальных механизмов аутентификации/безопасности пользователей и распространяет такие версии, модификатор/распространитель несет полную ответственность за любые последствия. Мы настоятельно не рекомендуем обходить или вмешиваться в любые механизмы проверки безопасности.\n\n4. Некоммерческое Заявление\nЭто полностью бесплатный проект с открытым исходным кодом. Разработчик не принимает пожертвования и никогда не публиковал страницы для пожертвований. Сама программа не предусматривает взимания платы и не предлагает платных услуг. Пожалуйста, будьте бдительны и остерегайтесь мошенничества или вводящей в заблуждение информации, утверждающей о сборе средств от имени этого проекта.",
// Visitor Mode
admin: "Администратор",
visitorSignIn: "Вход для посетителей",
visitorUser: "Посетитель",
visitorUserHelper:
"Включите отдельную учетную запись посетителя с доступом только для чтения. Посетители могут просматривать контент, но не могут вносить изменения.",
visitorPassword: "Пароль посетителя",
visitorPasswordHelper: "Установите пароль для учетной записи посетителя.",
visitorPasswordSetHelper:
"Пароль установлен. Оставьте пустым, чтобы сохранить его.",
};

View File

@@ -668,4 +668,12 @@ export const zh = {
disclaimerTitle: "免责声明",
disclaimerText:
"1. 用途与限制\n本软件包括代码和文档仅供个人学习、研究和技术交流使用。严禁将本软件用于任何商业用途或违反当地法律法规的非法活动。\n\n2. 责任\n开发者不知道也无法控制用户如何使用本软件。因非法或不当使用本软件而产生的任何法律责任、争议或损害包括但不限于侵犯版权应由用户自行承担。开发者不承担任何直接、间接或连带责任。\n\n3. 修改和分发\n本项目是开源的。任何修改或复刻此代码的个人或组织必须遵守开源许可证。重要提示如果第三方修改代码以绕过或移除原始用户验证/安全机制并分发此类版本,修改者/分发者将承担所有后果的全责。我们强烈不建议绕过或篡改任何安全验证机制。\n\n4. 非盈利声明\n这是一个完全免费的开源项目。开发者不接受捐赠也从未发布过任何捐赠页面。本软件本身不收费也不提供任何付费服务。请提高警惕谨防任何声称代表本项目收费的诈骗或误导信息。",
// Visitor Mode
admin: "管理员",
visitorSignIn: "访客登录",
visitorUser: "访客用户",
visitorUserHelper: "启用具有只读权限的单独访客帐户。访客可以查看内容,但不能进行更改。",
visitorPassword: "访客密码",
visitorPasswordHelper: "设置访客帐户的密码。",
visitorPasswordSetHelper: "密码已设置。留空以保持不变。",
};