first commit
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
# Linux Parental Control
|
||||||
|
|
||||||
|
A Linux parental-control system for managing Linux user access, daily time allowances, access windows, temporary grants, and automatic session enforcement.
|
||||||
|
|
||||||
|
> **Status:** Early development
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Per-user daily time allowances
|
||||||
|
- Different allowances for each day of the week
|
||||||
|
- Multiple access windows per day
|
||||||
|
- Temporary time grants
|
||||||
|
- Usage tracking
|
||||||
|
- Automatic session termination
|
||||||
|
- Automatic account locking
|
||||||
|
- Automatic account unlocking
|
||||||
|
- Reboot-safe enforcement
|
||||||
|
- Web-based administration
|
||||||
|
- SQLite database
|
||||||
|
- systemd service support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
The application currently targets Linux systems using `systemd`.
|
||||||
|
|
||||||
|
You need:
|
||||||
|
|
||||||
|
- Linux
|
||||||
|
- Python 3
|
||||||
|
- `python-venv`
|
||||||
|
- `pip`
|
||||||
|
- `systemd`
|
||||||
|
- `sudo`
|
||||||
|
- `passwd`
|
||||||
|
- `loginctl`
|
||||||
|
- Git
|
||||||
|
|
||||||
|
The enforcement service requires **root privileges** because it manages other Linux users and their sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Clone the Repository
|
||||||
|
|
||||||
|
Clone the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Alsantek-me/linux-user-timer.git
|
||||||
|
```
|
||||||
|
|
||||||
|
# 2.
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
DATABASE_DIR = BASE_DIR / "data"
|
||||||
|
DATABASE_PATH = DATABASE_DIR / "parental-control.db"
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_database():
|
||||||
|
DATABASE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with sqlite3.connect(DATABASE_PATH) as db:
|
||||||
|
db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_allowances (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
weekday INTEGER NOT NULL,
|
||||||
|
allowance_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
UNIQUE(user_id, weekday),
|
||||||
|
|
||||||
|
FOREIGN KEY(user_id)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS access_windows (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
weekday INTEGER NOT NULL,
|
||||||
|
start_minute INTEGER NOT NULL,
|
||||||
|
end_minute INTEGER NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY(user_id)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
used_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
UNIQUE(user_id, date),
|
||||||
|
|
||||||
|
FOREIGN KEY(user_id)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS temporary_grants (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
seconds INTEGER NOT NULL,
|
||||||
|
remaining_seconds INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TEXT,
|
||||||
|
consumed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
FOREIGN KEY(user_id)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db():
|
||||||
|
db = sqlite3.connect(DATABASE_PATH)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .database import get_db
|
||||||
|
from .users import (
|
||||||
|
lock_user,
|
||||||
|
unlock_user,
|
||||||
|
terminate_user,
|
||||||
|
is_locked,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_session(username: str) -> bool:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"loginctl",
|
||||||
|
"list-users",
|
||||||
|
"--no-legend",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
|
||||||
|
if len(parts) >= 2 and parts[1] == username:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def current_time():
|
||||||
|
now = datetime.now()
|
||||||
|
weekday = now.weekday()
|
||||||
|
minute = now.hour * 60 + now.minute
|
||||||
|
|
||||||
|
return now, weekday, minute
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_policy(user_id: int, weekday: int):
|
||||||
|
with get_db() as db:
|
||||||
|
allowance_row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT allowance_seconds
|
||||||
|
FROM daily_allowances
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND weekday = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
weekday,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
allowance_seconds = (
|
||||||
|
allowance_row["allowance_seconds"]
|
||||||
|
if allowance_row
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
|
||||||
|
usage_row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT used_seconds
|
||||||
|
FROM usage
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND date = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
today,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
usage_seconds = (
|
||||||
|
usage_row["used_seconds"]
|
||||||
|
if usage_row
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
windows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, start_minute, end_minute
|
||||||
|
FROM access_windows
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND weekday = ?
|
||||||
|
ORDER BY start_minute
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
weekday,
|
||||||
|
),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
grant_row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(
|
||||||
|
SUM(remaining_seconds),
|
||||||
|
0
|
||||||
|
) AS total
|
||||||
|
FROM temporary_grants
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND consumed = 0
|
||||||
|
AND (
|
||||||
|
expires_at IS NULL
|
||||||
|
OR expires_at > ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
datetime.now().isoformat(),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
grant_seconds = grant_row["total"]
|
||||||
|
|
||||||
|
return (
|
||||||
|
allowance_seconds,
|
||||||
|
usage_seconds,
|
||||||
|
windows,
|
||||||
|
grant_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_inside_window(windows, minute: int) -> bool:
|
||||||
|
if not windows:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for window in windows:
|
||||||
|
if (
|
||||||
|
window["start_minute"]
|
||||||
|
<= minute
|
||||||
|
< window["end_minute"]
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_remaining_grant_seconds(user_id: int) -> int:
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(
|
||||||
|
SUM(remaining_seconds),
|
||||||
|
0
|
||||||
|
) AS total
|
||||||
|
FROM temporary_grants
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND consumed = 0
|
||||||
|
AND (
|
||||||
|
expires_at IS NULL
|
||||||
|
OR expires_at > ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
return row["total"]
|
||||||
|
|
||||||
|
|
||||||
|
def consume_grant_seconds(
|
||||||
|
user_id: int,
|
||||||
|
seconds: int,
|
||||||
|
):
|
||||||
|
if seconds <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
grants = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, remaining_seconds
|
||||||
|
FROM temporary_grants
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND consumed = 0
|
||||||
|
AND remaining_seconds > 0
|
||||||
|
AND (
|
||||||
|
expires_at IS NULL
|
||||||
|
OR expires_at > ?
|
||||||
|
)
|
||||||
|
ORDER BY id ASC
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
remaining = seconds
|
||||||
|
|
||||||
|
for grant in grants:
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
available = grant["remaining_seconds"]
|
||||||
|
|
||||||
|
consumed = min(
|
||||||
|
available,
|
||||||
|
remaining,
|
||||||
|
)
|
||||||
|
|
||||||
|
new_remaining = (
|
||||||
|
available - consumed
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE temporary_grants
|
||||||
|
SET remaining_seconds = ?,
|
||||||
|
consumed = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
new_remaining,
|
||||||
|
1 if new_remaining <= 0 else 0,
|
||||||
|
grant["id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining -= consumed
|
||||||
|
|
||||||
|
|
||||||
|
def record_usage(
|
||||||
|
user_id: int,
|
||||||
|
seconds: int,
|
||||||
|
):
|
||||||
|
if seconds <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT used_seconds
|
||||||
|
FROM usage
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND date = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
today,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO usage (
|
||||||
|
user_id,
|
||||||
|
date,
|
||||||
|
used_seconds
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
today,
|
||||||
|
seconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE usage
|
||||||
|
SET used_seconds =
|
||||||
|
used_seconds + ?
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND date = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
seconds,
|
||||||
|
user_id,
|
||||||
|
today,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_event(
|
||||||
|
user_id: int,
|
||||||
|
event_type: str,
|
||||||
|
details: str = "",
|
||||||
|
):
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO events (
|
||||||
|
user_id,
|
||||||
|
event_type,
|
||||||
|
details
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
event_type,
|
||||||
|
details,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_user(
|
||||||
|
user_id: int,
|
||||||
|
username: str,
|
||||||
|
):
|
||||||
|
now, weekday, minute = current_time()
|
||||||
|
|
||||||
|
(
|
||||||
|
allowance_seconds,
|
||||||
|
usage_seconds,
|
||||||
|
windows,
|
||||||
|
grant_seconds,
|
||||||
|
) = get_user_policy(
|
||||||
|
user_id,
|
||||||
|
weekday,
|
||||||
|
)
|
||||||
|
|
||||||
|
inside_window = is_inside_window(
|
||||||
|
windows,
|
||||||
|
minute,
|
||||||
|
)
|
||||||
|
|
||||||
|
allowance_remaining = max(
|
||||||
|
0,
|
||||||
|
allowance_seconds - usage_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_remaining = (
|
||||||
|
allowance_remaining
|
||||||
|
+ grant_seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
logged_in = user_has_session(
|
||||||
|
username
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_by_schedule = (
|
||||||
|
inside_window
|
||||||
|
and allowance_remaining > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_by_grant = (
|
||||||
|
grant_seconds > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
should_allow = (
|
||||||
|
allowed_by_schedule
|
||||||
|
or allowed_by_grant
|
||||||
|
)
|
||||||
|
|
||||||
|
locked = is_locked(username)
|
||||||
|
|
||||||
|
if should_allow:
|
||||||
|
if locked:
|
||||||
|
try:
|
||||||
|
unlock_user(username)
|
||||||
|
|
||||||
|
record_event(
|
||||||
|
user_id,
|
||||||
|
"auto_unlock",
|
||||||
|
"Access became available",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
record_event(
|
||||||
|
user_id,
|
||||||
|
"unlock_error",
|
||||||
|
str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if logged_in:
|
||||||
|
terminate_user(username)
|
||||||
|
|
||||||
|
record_event(
|
||||||
|
user_id,
|
||||||
|
"session_terminated",
|
||||||
|
"Access is not currently permitted",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not locked:
|
||||||
|
try:
|
||||||
|
lock_user(username)
|
||||||
|
|
||||||
|
record_event(
|
||||||
|
user_id,
|
||||||
|
"auto_lock",
|
||||||
|
"Access is not currently permitted",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
record_event(
|
||||||
|
user_id,
|
||||||
|
"lock_error",
|
||||||
|
str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"username": username,
|
||||||
|
"timestamp": now.isoformat(),
|
||||||
|
"weekday": weekday,
|
||||||
|
"minute": minute,
|
||||||
|
"inside_window": inside_window,
|
||||||
|
"logged_in": logged_in,
|
||||||
|
"allowance_seconds": allowance_seconds,
|
||||||
|
"usage_seconds": usage_seconds,
|
||||||
|
"allowance_remaining": allowance_remaining,
|
||||||
|
"grant_seconds": grant_seconds,
|
||||||
|
"total_remaining": total_remaining,
|
||||||
|
"allowed": should_allow,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_all_users():
|
||||||
|
with get_db() as db:
|
||||||
|
users = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, enabled
|
||||||
|
FROM users
|
||||||
|
WHERE enabled = 1
|
||||||
|
ORDER BY id
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
result = evaluate_user(
|
||||||
|
user["id"],
|
||||||
|
user["username"],
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
record_event(
|
||||||
|
user["id"],
|
||||||
|
"enforcement_error",
|
||||||
|
str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
+817
@@ -0,0 +1,817 @@
|
|||||||
|
from fastapi import (
|
||||||
|
FastAPI,
|
||||||
|
HTTPException,
|
||||||
|
Request,
|
||||||
|
Form,
|
||||||
|
)
|
||||||
|
|
||||||
|
from fastapi.responses import (
|
||||||
|
RedirectResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .scheduler import (
|
||||||
|
start_scheduler,
|
||||||
|
stop_scheduler,
|
||||||
|
)
|
||||||
|
|
||||||
|
from fastapi.templating import (
|
||||||
|
Jinja2Templates,
|
||||||
|
)
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
from .database import (
|
||||||
|
initialize_database,
|
||||||
|
get_db,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .users import (
|
||||||
|
linux_user_exists,
|
||||||
|
lock_user,
|
||||||
|
unlock_user,
|
||||||
|
terminate_user,
|
||||||
|
is_locked,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Parental Control",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
templates = Jinja2Templates(
|
||||||
|
directory="templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
WEEKDAYS = [
|
||||||
|
(0, "Monday"),
|
||||||
|
(1, "Tuesday"),
|
||||||
|
(2, "Wednesday"),
|
||||||
|
(3, "Thursday"),
|
||||||
|
(4, "Friday"),
|
||||||
|
(5, "Saturday"),
|
||||||
|
(6, "Sunday"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup():
|
||||||
|
initialize_database()
|
||||||
|
start_scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
def shutdown():
|
||||||
|
stop_scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
class AllowanceRequest(BaseModel):
|
||||||
|
weekday: int
|
||||||
|
seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
class WindowRequest(BaseModel):
|
||||||
|
weekday: int
|
||||||
|
start_minute: int
|
||||||
|
end_minute: int
|
||||||
|
|
||||||
|
|
||||||
|
class GrantRequest(BaseModel):
|
||||||
|
seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(user_id: int):
|
||||||
|
with get_db() as db:
|
||||||
|
return db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, enabled
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def root():
|
||||||
|
return {
|
||||||
|
"application": "Parental Control",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/users")
|
||||||
|
def list_users():
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, enabled
|
||||||
|
FROM users
|
||||||
|
ORDER BY username
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"enabled": bool(row["enabled"]),
|
||||||
|
"locked": is_locked(row["username"])
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/users/{user_id}/lock")
|
||||||
|
def manually_lock(user_id: int):
|
||||||
|
row = get_user(user_id)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
lock_user(row["username"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"username": row["username"],
|
||||||
|
"locked": True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/users/{user_id}/unlock")
|
||||||
|
def manually_unlock(user_id: int):
|
||||||
|
row = get_user(user_id)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
unlock_user(row["username"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"username": row["username"],
|
||||||
|
"locked": False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/users/{user_id}/terminate")
|
||||||
|
def manually_terminate(user_id: int):
|
||||||
|
row = get_user(user_id)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
terminate_user(row["username"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"username": row["username"],
|
||||||
|
"terminated": True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/users/{user_id}/allowance")
|
||||||
|
def set_allowance(
|
||||||
|
user_id: int,
|
||||||
|
request: AllowanceRequest
|
||||||
|
):
|
||||||
|
if get_user(user_id) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.weekday < 0 or request.weekday > 6:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Invalid weekday"
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.seconds < 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Allowance cannot be negative"
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_allowances
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
weekday,
|
||||||
|
allowance_seconds
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, weekday)
|
||||||
|
DO UPDATE SET
|
||||||
|
allowance_seconds = excluded.allowance_seconds
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
request.weekday,
|
||||||
|
request.seconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"weekday": request.weekday,
|
||||||
|
"seconds": request.seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/users/{user_id}/windows")
|
||||||
|
def add_window(
|
||||||
|
user_id: int,
|
||||||
|
request: WindowRequest
|
||||||
|
):
|
||||||
|
if get_user(user_id) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.weekday < 0 or request.weekday > 6:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Invalid weekday"
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.start_minute < 0 or request.start_minute >= 1440:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Invalid start time"
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.end_minute < 0 or request.end_minute > 1440:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Invalid end time"
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.end_minute <= request.start_minute:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="End time must be after start time"
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO access_windows
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
weekday,
|
||||||
|
start_minute,
|
||||||
|
end_minute
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
request.weekday,
|
||||||
|
request.start_minute,
|
||||||
|
request.end_minute
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "created"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/windows/{window_id}")
|
||||||
|
def delete_window(window_id: int):
|
||||||
|
with get_db() as db:
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM access_windows
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(window_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Window not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "deleted"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/users/{user_id}/grant")
|
||||||
|
def grant_time(
|
||||||
|
user_id: int,
|
||||||
|
request: GrantRequest
|
||||||
|
):
|
||||||
|
if get_user(user_id) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.seconds <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Grant must be greater than zero"
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO temporary_grants
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
seconds,
|
||||||
|
remaining_seconds
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
request.seconds,
|
||||||
|
request.seconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"seconds": request.seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
def admin_page(
|
||||||
|
request: Request
|
||||||
|
):
|
||||||
|
with get_db() as db:
|
||||||
|
users = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, enabled
|
||||||
|
FROM users
|
||||||
|
ORDER BY username
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="index.html",
|
||||||
|
context={
|
||||||
|
"users": users
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/users/{user_id}")
|
||||||
|
def admin_user_page(
|
||||||
|
request: Request,
|
||||||
|
user_id: int
|
||||||
|
):
|
||||||
|
user = get_user(user_id)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
|
||||||
|
allowances = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT weekday, allowance_seconds
|
||||||
|
FROM daily_allowances
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY weekday
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
windows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
weekday,
|
||||||
|
start_minute,
|
||||||
|
end_minute
|
||||||
|
FROM access_windows
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY weekday, start_minute
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
grants = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
seconds,
|
||||||
|
created_at,
|
||||||
|
expires_at,
|
||||||
|
consumed
|
||||||
|
FROM temporary_grants
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
allowance_map = {
|
||||||
|
row["weekday"]: row["allowance_seconds"]
|
||||||
|
for row in allowances
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="user.html",
|
||||||
|
context={
|
||||||
|
"user": user,
|
||||||
|
"locked": is_locked(user["username"]),
|
||||||
|
"weekdays": WEEKDAYS,
|
||||||
|
"allowances": allowance_map,
|
||||||
|
"windows": windows,
|
||||||
|
"grants": grants,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/users/{user_id}/allowance")
|
||||||
|
def admin_set_allowance(
|
||||||
|
user_id: int,
|
||||||
|
weekday: int = Form(...),
|
||||||
|
hours: int = Form(...),
|
||||||
|
minutes: int = Form(...),
|
||||||
|
):
|
||||||
|
if get_user(user_id) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if weekday < 0 or weekday > 6:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Invalid weekday"
|
||||||
|
)
|
||||||
|
|
||||||
|
if hours < 0 or minutes < 0 or minutes > 59:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Invalid time"
|
||||||
|
)
|
||||||
|
|
||||||
|
seconds = (hours * 3600) + (minutes * 60)
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_allowances
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
weekday,
|
||||||
|
allowance_seconds
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, weekday)
|
||||||
|
DO UPDATE SET
|
||||||
|
allowance_seconds = excluded.allowance_seconds
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
weekday,
|
||||||
|
seconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/users/{user_id}",
|
||||||
|
status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/users/{user_id}/window")
|
||||||
|
def admin_add_window(
|
||||||
|
user_id: int,
|
||||||
|
weekday: int = Form(...),
|
||||||
|
start_time: str = Form(...),
|
||||||
|
end_time: str = Form(...),
|
||||||
|
):
|
||||||
|
if get_user(user_id) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_hour, start_minute = map(
|
||||||
|
int,
|
||||||
|
start_time.split(":")
|
||||||
|
)
|
||||||
|
|
||||||
|
end_hour, end_minute = map(
|
||||||
|
int,
|
||||||
|
end_time.split(":")
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Invalid time format"
|
||||||
|
)
|
||||||
|
|
||||||
|
start_total = (
|
||||||
|
start_hour * 60
|
||||||
|
+ start_minute
|
||||||
|
)
|
||||||
|
|
||||||
|
end_total = (
|
||||||
|
end_hour * 60
|
||||||
|
+ end_minute
|
||||||
|
)
|
||||||
|
|
||||||
|
if weekday < 0 or weekday > 6:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Invalid weekday"
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_total < 0 or start_total >= 1440:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Invalid start time"
|
||||||
|
)
|
||||||
|
|
||||||
|
if end_total <= start_total:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="End time must be after start time"
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO access_windows
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
weekday,
|
||||||
|
start_minute,
|
||||||
|
end_minute
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
weekday,
|
||||||
|
start_total,
|
||||||
|
end_total
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/users/{user_id}",
|
||||||
|
status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/windows/{window_id}/delete")
|
||||||
|
def admin_delete_window(
|
||||||
|
window_id: int
|
||||||
|
):
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT user_id
|
||||||
|
FROM access_windows
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(window_id,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Window not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = row["user_id"]
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM access_windows
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(window_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/users/{user_id}",
|
||||||
|
status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/users/{user_id}/grant")
|
||||||
|
def admin_grant_time(
|
||||||
|
user_id: int,
|
||||||
|
hours: int = Form(...),
|
||||||
|
minutes: int = Form(...),
|
||||||
|
):
|
||||||
|
if get_user(user_id) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if hours < 0 or minutes < 0 or minutes > 59:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Invalid time"
|
||||||
|
)
|
||||||
|
|
||||||
|
seconds = (
|
||||||
|
hours * 3600
|
||||||
|
+ minutes * 60
|
||||||
|
)
|
||||||
|
|
||||||
|
if seconds <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Grant must be greater than zero"
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO temporary_grants
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
seconds,
|
||||||
|
remaining_seconds
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
seconds,
|
||||||
|
seconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/users/{user_id}",
|
||||||
|
status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/users/{user_id}/lock")
|
||||||
|
def admin_lock_user(
|
||||||
|
user_id: int
|
||||||
|
):
|
||||||
|
row = get_user(user_id)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
lock_user(row["username"])
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/users/{user_id}",
|
||||||
|
status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/users/{user_id}/unlock")
|
||||||
|
def admin_unlock_user(
|
||||||
|
user_id: int
|
||||||
|
):
|
||||||
|
row = get_user(user_id)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
unlock_user(row["username"])
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/users/{user_id}",
|
||||||
|
status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/users/{user_id}/terminate")
|
||||||
|
def admin_terminate_user(
|
||||||
|
user_id: int
|
||||||
|
):
|
||||||
|
row = get_user(user_id)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
terminate_user(row["username"])
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/users/{user_id}",
|
||||||
|
status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/users/{user_id}/delete")
|
||||||
|
def delete_user(
|
||||||
|
user_id: int
|
||||||
|
):
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
"/admin",
|
||||||
|
status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/users")
|
||||||
|
def admin_add_user(
|
||||||
|
username: str = Form(...)
|
||||||
|
):
|
||||||
|
username = username.strip()
|
||||||
|
|
||||||
|
if not linux_user_exists(username):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Linux user does not exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
|
||||||
|
existing = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
WHERE username = ?
|
||||||
|
""",
|
||||||
|
(username,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="User is already configured"
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users(username)
|
||||||
|
VALUES(?)
|
||||||
|
""",
|
||||||
|
(username,)
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = cursor.lastrowid
|
||||||
|
|
||||||
|
for weekday in range(7):
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_allowances
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
weekday,
|
||||||
|
allowance_seconds
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
weekday,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
"/admin",
|
||||||
|
status_code=303
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class User:
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DailyAllowance:
|
||||||
|
user_id: int
|
||||||
|
weekday: int
|
||||||
|
allowance_seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AccessWindow:
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
weekday: int
|
||||||
|
start_minute: int
|
||||||
|
end_minute: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TemporaryGrant:
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
seconds: int
|
||||||
|
expires_at: Optional[str]
|
||||||
|
consumed: bool
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .enforcement import (
|
||||||
|
enforce_all_users,
|
||||||
|
record_usage,
|
||||||
|
get_user_policy,
|
||||||
|
consume_grant_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CHECK_INTERVAL = 5
|
||||||
|
|
||||||
|
|
||||||
|
class Scheduler:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
interval: int = CHECK_INTERVAL,
|
||||||
|
):
|
||||||
|
self.interval = interval
|
||||||
|
|
||||||
|
self._thread = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
|
self._last_usage_update = {}
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._thread is not None
|
||||||
|
and self._thread.is_alive()
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._stop_event.clear()
|
||||||
|
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run,
|
||||||
|
name="parental-control-scheduler",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join(
|
||||||
|
timeout=self.interval + 2
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
|
||||||
|
# Evaluate immediately when the
|
||||||
|
# application starts.
|
||||||
|
self._tick()
|
||||||
|
|
||||||
|
while not self._stop_event.wait(
|
||||||
|
self.interval
|
||||||
|
):
|
||||||
|
self._tick()
|
||||||
|
|
||||||
|
def _tick(self):
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
results = enforce_all_users()
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
|
||||||
|
user_id = result["user_id"]
|
||||||
|
|
||||||
|
if not result["logged_in"]:
|
||||||
|
self._last_usage_update.pop(
|
||||||
|
user_id,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not result["allowed"]:
|
||||||
|
self._last_usage_update.pop(
|
||||||
|
user_id,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
previous = (
|
||||||
|
self._last_usage_update.get(
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._last_usage_update[user_id] = now
|
||||||
|
|
||||||
|
if previous is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
elapsed = int(
|
||||||
|
now - previous
|
||||||
|
)
|
||||||
|
|
||||||
|
if elapsed <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._record_allowed_usage(
|
||||||
|
user_id,
|
||||||
|
elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _record_allowed_usage(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
seconds: int,
|
||||||
|
):
|
||||||
|
|
||||||
|
if seconds <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
weekday = datetime.now().weekday()
|
||||||
|
|
||||||
|
(
|
||||||
|
allowance_seconds,
|
||||||
|
usage_seconds,
|
||||||
|
windows,
|
||||||
|
grant_seconds,
|
||||||
|
) = get_user_policy(
|
||||||
|
user_id,
|
||||||
|
weekday,
|
||||||
|
)
|
||||||
|
|
||||||
|
allowance_remaining = max(
|
||||||
|
0,
|
||||||
|
allowance_seconds
|
||||||
|
- usage_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
normal_usage = min(
|
||||||
|
seconds,
|
||||||
|
allowance_remaining,
|
||||||
|
)
|
||||||
|
|
||||||
|
grant_usage = (
|
||||||
|
seconds
|
||||||
|
- normal_usage
|
||||||
|
)
|
||||||
|
|
||||||
|
if grant_usage > grant_seconds:
|
||||||
|
grant_usage = grant_seconds
|
||||||
|
|
||||||
|
total_usage = (
|
||||||
|
normal_usage
|
||||||
|
+ grant_usage
|
||||||
|
)
|
||||||
|
|
||||||
|
if total_usage <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
record_usage(
|
||||||
|
user_id,
|
||||||
|
total_usage,
|
||||||
|
)
|
||||||
|
|
||||||
|
if grant_usage > 0:
|
||||||
|
|
||||||
|
consume_grant_seconds(
|
||||||
|
user_id,
|
||||||
|
grant_usage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
scheduler = Scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler():
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler():
|
||||||
|
scheduler.stop()
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import pwd
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def linux_user_exists(username: str) -> bool:
|
||||||
|
try:
|
||||||
|
pwd.getpwnam(username)
|
||||||
|
return True
|
||||||
|
except KeyError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_uid(username: str) -> int:
|
||||||
|
return pwd.getpwnam(username).pw_uid
|
||||||
|
|
||||||
|
|
||||||
|
def is_locked(username: str) -> bool:
|
||||||
|
result = subprocess.run(
|
||||||
|
["passwd", "-S", username],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
parts = result.stdout.split()
|
||||||
|
|
||||||
|
if len(parts) < 2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return parts[1] == "L"
|
||||||
|
|
||||||
|
|
||||||
|
def lock_user(username: str):
|
||||||
|
subprocess.run(
|
||||||
|
["loginctl", "terminate-user", username],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
["passwd", "-l", username],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unlock_user(username: str):
|
||||||
|
subprocess.run(
|
||||||
|
["passwd", "-u", username],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def terminate_user(username: str):
|
||||||
|
subprocess.run(
|
||||||
|
["loginctl", "terminate-user", username],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "Please run with sudo"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "Installing from:"
|
||||||
|
echo "$INSTALL_DIR"
|
||||||
|
|
||||||
|
|
||||||
|
echo "[1/5] Installing dependencies"
|
||||||
|
|
||||||
|
pacman -S --needed --noconfirm python python-pip
|
||||||
|
|
||||||
|
|
||||||
|
echo "[2/5] Creating virtual environment"
|
||||||
|
|
||||||
|
if [ ! -d "$INSTALL_DIR/.venv" ]; then
|
||||||
|
python -m venv "$INSTALL_DIR/.venv"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
echo "[3/5] Installing Python packages"
|
||||||
|
|
||||||
|
"$INSTALL_DIR/.venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt"
|
||||||
|
|
||||||
|
|
||||||
|
echo "[4/5] Installing systemd service"
|
||||||
|
|
||||||
|
sed \
|
||||||
|
"s|%INSTALL_DIR%|$INSTALL_DIR|g" \
|
||||||
|
"$INSTALL_DIR/parental-control.service" \
|
||||||
|
> /etc/systemd/system/parental-control.service
|
||||||
|
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
systemctl enable parental-control.service
|
||||||
|
|
||||||
|
systemctl restart parental-control.service
|
||||||
|
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=================================="
|
||||||
|
echo "Parental Control installed"
|
||||||
|
echo
|
||||||
|
echo "Admin panel:"
|
||||||
|
echo "http://127.0.0.1:8765/admin"
|
||||||
|
echo
|
||||||
|
echo "Service status:"
|
||||||
|
echo "systemctl status parental-control"
|
||||||
|
echo "=================================="
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Linux Parental Control
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
|
||||||
|
WorkingDirectory=%INSTALL_DIR%
|
||||||
|
|
||||||
|
Environment="PATH=%INSTALL_DIR%/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin"
|
||||||
|
Environment="PARENTAL_CONTROL_ENFORCEMENT=1"
|
||||||
|
|
||||||
|
ExecStart=%INSTALL_DIR%/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8765
|
||||||
|
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Linux Parental Control
|
||||||
|
|
||||||
|
A Linux parental-control system for managing Linux user access, daily time allowances, access windows, temporary grants, and automatic session enforcement.
|
||||||
|
|
||||||
|
> **Status:** Early development
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Per-user daily time allowances
|
||||||
|
- Different allowances for each day of the week
|
||||||
|
- Multiple access windows per day
|
||||||
|
- Temporary time grants
|
||||||
|
- Usage tracking
|
||||||
|
- Automatic session termination
|
||||||
|
- Automatic account locking
|
||||||
|
- Automatic account unlocking
|
||||||
|
- Reboot-safe enforcement
|
||||||
|
- Web-based administration
|
||||||
|
- SQLite database
|
||||||
|
- systemd service support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
The application currently targets Linux systems using `systemd`.
|
||||||
|
|
||||||
|
You need:
|
||||||
|
|
||||||
|
- Linux
|
||||||
|
- Python 3
|
||||||
|
- `python-venv`
|
||||||
|
- `pip`
|
||||||
|
- `systemd`
|
||||||
|
- `sudo`
|
||||||
|
- `passwd`
|
||||||
|
- `loginctl`
|
||||||
|
- Git
|
||||||
|
|
||||||
|
The enforcement service requires **root privileges** because it manages other Linux users and their sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Clone the Repository
|
||||||
|
|
||||||
|
Clone the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Alsantek-me/linux-user-timer.git
|
||||||
|
```
|
||||||
|
|
||||||
|
# 2.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
annotated-doc==0.0.5
|
||||||
|
annotated-types==0.8.0
|
||||||
|
anyio==4.15.1
|
||||||
|
click==8.5.0
|
||||||
|
fastapi==0.141.1
|
||||||
|
h11==0.16.0
|
||||||
|
idna==3.19
|
||||||
|
Jinja2==3.1.6
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
pydantic==2.13.5
|
||||||
|
pydantic_core==2.46.5
|
||||||
|
python-multipart==0.0.32
|
||||||
|
starlette==1.6.0
|
||||||
|
typing-inspection==0.4.4
|
||||||
|
typing_extensions==4.16.0
|
||||||
|
uvicorn==0.53.0
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0"
|
||||||
|
>
|
||||||
|
|
||||||
|
<title>Parental Control</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
|
||||||
|
background: #f4f5f7;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
header {
|
||||||
|
|
||||||
|
background: #111827;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
padding: 20px 30px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
main {
|
||||||
|
|
||||||
|
max-width: 1100px;
|
||||||
|
|
||||||
|
margin: 30px auto;
|
||||||
|
|
||||||
|
padding: 0 20px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
margin-bottom: 25px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.topbar h2 {
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.card {
|
||||||
|
|
||||||
|
background: white;
|
||||||
|
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.user-header {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.username {
|
||||||
|
|
||||||
|
font-size: 20px;
|
||||||
|
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.status {
|
||||||
|
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
padding: 5px 10px;
|
||||||
|
|
||||||
|
border-radius: 20px;
|
||||||
|
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.status-enabled {
|
||||||
|
|
||||||
|
background: #dcfce7;
|
||||||
|
|
||||||
|
color: #166534;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.status-disabled {
|
||||||
|
|
||||||
|
background: #fee2e2;
|
||||||
|
|
||||||
|
color: #991b1b;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
margin-top: 15px;
|
||||||
|
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
button {
|
||||||
|
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
border-radius: 7px;
|
||||||
|
|
||||||
|
padding: 9px 15px;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
|
||||||
|
background: #2563eb;
|
||||||
|
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.button-danger {
|
||||||
|
|
||||||
|
background: #dc2626;
|
||||||
|
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
|
||||||
|
background: #e5e7eb;
|
||||||
|
|
||||||
|
color: #111827;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.add-user {
|
||||||
|
|
||||||
|
margin-top: 30px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.add-user form {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
input {
|
||||||
|
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
|
||||||
|
border-radius: 7px;
|
||||||
|
|
||||||
|
padding: 9px 12px;
|
||||||
|
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
|
||||||
|
color: #6b7280;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
|
||||||
|
<header>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
Parental Control
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="topbar">
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
Users
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% if users %}
|
||||||
|
|
||||||
|
|
||||||
|
{% for user in users %}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="user-header">
|
||||||
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div class="username">
|
||||||
|
|
||||||
|
{{ user.username }}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
Linux user ID:
|
||||||
|
{{ user.id }}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% if user.enabled %}
|
||||||
|
|
||||||
|
<span class="status status-enabled">
|
||||||
|
|
||||||
|
Enabled
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<span class="status status-disabled">
|
||||||
|
|
||||||
|
Disabled
|
||||||
|
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/admin/users/{{ user.id }}"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="button-primary"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
|
||||||
|
Manage
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/admin/users/{{ user.id }}/delete"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="button-danger"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
|
||||||
|
Delete
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="card empty">
|
||||||
|
|
||||||
|
No users configured yet.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="card add-user">
|
||||||
|
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
Add User
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
|
||||||
|
Add an existing Linux account to
|
||||||
|
parental control.
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/admin/users"
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
placeholder="Linux username"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="button-primary"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
|
||||||
|
Add User
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,859 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0"
|
||||||
|
>
|
||||||
|
|
||||||
|
<title>
|
||||||
|
Manage {{ user.username }}
|
||||||
|
</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
|
||||||
|
background: #f4f5f7;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: #111827;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
padding: 20px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 1100px;
|
||||||
|
|
||||||
|
margin: 30px auto;
|
||||||
|
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
color: #2563eb;
|
||||||
|
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 2px 8px rgba(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.08
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-size: 28px;
|
||||||
|
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
padding: 6px 12px;
|
||||||
|
|
||||||
|
border-radius: 20px;
|
||||||
|
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-locked {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-unlocked {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
border-radius: 7px;
|
||||||
|
|
||||||
|
padding: 9px 15px;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
background: #d97706;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
|
||||||
|
border-radius: 7px;
|
||||||
|
|
||||||
|
padding: 8px 10px;
|
||||||
|
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="time"] {
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-form {
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
grid-template-columns:
|
||||||
|
150px
|
||||||
|
130px
|
||||||
|
130px
|
||||||
|
auto;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
gap: 15px;
|
||||||
|
|
||||||
|
padding: 12px 0;
|
||||||
|
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #6b7280;
|
||||||
|
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone {
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
|
||||||
|
background: #fff7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grant-box {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
|
||||||
|
table {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
Parental Control
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="back"
|
||||||
|
href="/admin"
|
||||||
|
>
|
||||||
|
← Back to Users
|
||||||
|
</a>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- USER HEADER -->
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
|
||||||
|
<div class="header-row">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<div class="username">
|
||||||
|
{{ user.username }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="muted">
|
||||||
|
Linux user ID:
|
||||||
|
{{ user.id }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% if locked %}
|
||||||
|
|
||||||
|
<span class="status status-locked">
|
||||||
|
Locked
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<span class="status status-unlocked">
|
||||||
|
Unlocked
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
|
||||||
|
{% if locked %}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/admin/users/{{ user.id }}/unlock"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="primary"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Unlock
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/admin/users/{{ user.id }}/lock"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="warning"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Lock
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/admin/users/{{ user.id }}/terminate"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="secondary"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Terminate Session
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- DAILY ALLOWANCES -->
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
Daily Allowance
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="muted">
|
||||||
|
Maximum amount of usage allowed on each day.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<table>
|
||||||
|
|
||||||
|
<thead>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
Day
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
Hours
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
Minutes
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
Save
|
||||||
|
</th>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
{% for weekday, name in weekdays %}
|
||||||
|
|
||||||
|
{% set total_seconds = allowances.get(
|
||||||
|
weekday,
|
||||||
|
0
|
||||||
|
) %}
|
||||||
|
|
||||||
|
{% set total_minutes = total_seconds // 60 %}
|
||||||
|
|
||||||
|
{% set hours = total_minutes // 60 %}
|
||||||
|
|
||||||
|
{% set minutes = total_minutes % 60 %}
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<strong>
|
||||||
|
{{ name }}
|
||||||
|
</strong>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td colspan="2">
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="inline-form"
|
||||||
|
method="post"
|
||||||
|
action="/admin/users/{{ user.id }}/allowance"
|
||||||
|
>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="weekday"
|
||||||
|
value="{{ weekday }}"
|
||||||
|
>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="hours"
|
||||||
|
min="0"
|
||||||
|
value="{{ hours }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
hours
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="minutes"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
value="{{ minutes }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
minutes
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="primary"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ACCESS WINDOWS -->
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
Access Windows
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="muted">
|
||||||
|
Define when this user is allowed to access
|
||||||
|
the computer. Multiple windows can be created
|
||||||
|
for the same day.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
{% for weekday, name in weekdays %}
|
||||||
|
|
||||||
|
{% set day_windows = [] %}
|
||||||
|
|
||||||
|
{% for window in windows %}
|
||||||
|
|
||||||
|
{% if window.weekday == weekday %}
|
||||||
|
|
||||||
|
{% set _ = day_windows.append(window) %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
|
||||||
|
<h3>
|
||||||
|
{{ name }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
|
||||||
|
{% for window in day_windows %}
|
||||||
|
|
||||||
|
{% set start_hour =
|
||||||
|
window.start_minute // 60
|
||||||
|
%}
|
||||||
|
|
||||||
|
{% set start_min =
|
||||||
|
window.start_minute % 60
|
||||||
|
%}
|
||||||
|
|
||||||
|
{% set end_hour =
|
||||||
|
window.end_minute // 60
|
||||||
|
%}
|
||||||
|
|
||||||
|
{% set end_min =
|
||||||
|
window.end_minute % 60
|
||||||
|
%}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="window">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<strong>
|
||||||
|
{{ "%02d:%02d" | format(
|
||||||
|
start_hour,
|
||||||
|
start_min
|
||||||
|
) }}
|
||||||
|
|
||||||
|
–
|
||||||
|
|
||||||
|
{{ "%02d:%02d" | format(
|
||||||
|
end_hour,
|
||||||
|
end_min
|
||||||
|
) }}
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/admin/windows/{{ window.id }}/delete"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="danger"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="window-form"
|
||||||
|
method="post"
|
||||||
|
action="/admin/users/{{ user.id }}/window"
|
||||||
|
style="margin-top: 12px;"
|
||||||
|
>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="weekday"
|
||||||
|
value="{{ weekday }}"
|
||||||
|
>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
name="start_time"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
name="end_time"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="primary"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Add Window
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- TEMPORARY GRANT -->
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
Give Temporary Time
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="muted">
|
||||||
|
Add extra time that can be used outside the
|
||||||
|
normal allowance.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="grant-box"
|
||||||
|
method="post"
|
||||||
|
action="/admin/users/{{ user.id }}/grant"
|
||||||
|
>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="hours"
|
||||||
|
min="0"
|
||||||
|
value="0"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
hours
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="minutes"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
value="5"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
minutes
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="primary"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Give Time
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- GRANT HISTORY -->
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
Temporary Grants
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{% if grants %}
|
||||||
|
|
||||||
|
<table>
|
||||||
|
|
||||||
|
<thead>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
Time
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
{% for grant in grants %}
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
|
||||||
|
{% set grant_minutes =
|
||||||
|
grant.seconds // 60
|
||||||
|
%}
|
||||||
|
|
||||||
|
{% set grant_hours =
|
||||||
|
grant_minutes // 60
|
||||||
|
%}
|
||||||
|
|
||||||
|
{% set grant_remaining =
|
||||||
|
grant_minutes % 60
|
||||||
|
%}
|
||||||
|
|
||||||
|
{{ grant_hours }}h
|
||||||
|
{{ grant_remaining }}m
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{{ grant.created_at }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
|
||||||
|
{% if grant.consumed %}
|
||||||
|
|
||||||
|
Used
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
Available
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<p class="muted">
|
||||||
|
No temporary grants yet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- DANGER ZONE -->
|
||||||
|
|
||||||
|
<div class="card danger-zone">
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
Danger Zone
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="muted">
|
||||||
|
Removing this user deletes their parental
|
||||||
|
control configuration from this application.
|
||||||
|
It does not delete the Linux account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/admin/users/{{ user.id }}/delete"
|
||||||
|
onsubmit="
|
||||||
|
return confirm(
|
||||||
|
'Remove this user from parental control?'
|
||||||
|
);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="danger"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Remove from Parental Control
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user