This commit is contained in:
timka123 committed 2026-10-19 09:41:32 +03:00
1 parent 4bdbc8bfa8
commit 2be074f191
22 files changed
+1034 -25

No files matched your search

+36 -1
View File
@@ -3,9 +3,11 @@ import { Alert, Box, CssBaseline, LinearProgress, Snackbar, ThemeProvider, useMe
import { Navigate, Route, Routes } from "react-router-dom";
import AppShell from "./components/AppShell";
import { AuthProvider, useAuth } from "./lib/auth";
import { SettingsProvider, useSettings } from "./lib/settings";
import { buildTheme, type ThemeMode } from "./theme";
import BlockerPage from "./pages/BlockerPage";
import LoginPage from "./pages/LoginPage";
import ReportPage from "./pages/ReportPage";
import SettingsPage from "./pages/SettingsPage";
import TasksPage from "./pages/TasksPage";
@@ -66,10 +68,43 @@ function Shell() {
);
}
export default function App() {
function Gate() {
const { me, loading } = useAuth();
const prefersDark = useMediaQuery("(prefers-color-scheme: dark)");
if (loading || !me) {
// The paper theme, so the blank moment before `/api/auth/me` answers
// doesn't flash a default MUI white.
const theme = buildTheme(prefersDark ? "dark" : "light", false);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<LinearProgress />
</ThemeProvider>
);
}
if (!me.authenticated) {
const theme = buildTheme(prefersDark ? "dark" : "light", false);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<LoginPage />
</ThemeProvider>
);
}
return (
<SettingsProvider>
<Shell />
</SettingsProvider>
);
}
export default function App() {
return (
<AuthProvider>
<Gate />
</AuthProvider>
);
}
+28
View File
@@ -22,11 +22,13 @@ import {
} from "@mui/material";
import AssignmentOutlinedIcon from "@mui/icons-material/AssignmentOutlined";
import BarChartOutlinedIcon from "@mui/icons-material/BarChartOutlined";
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import TimerOutlinedIcon from "@mui/icons-material/TimerOutlined";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../lib/auth";
import { clock } from "../lib/format";
import { useResource } from "../lib/hooks";
import type { BlockerPolicy, TimerState } from "../lib/types";
@@ -55,6 +57,7 @@ export default function AppShell({
const navigate = useNavigate();
const location = useLocation();
const palette = vizPalette(dark);
const { me, logout } = useAuth();
const timer = useResource<TimerState>("/api/timer/state", 5000);
const policy = useResource<BlockerPolicy>("/api/blocker/policy", 10000);
@@ -187,6 +190,23 @@ export default function AppShell({
<Divider sx={{ mb: 1.5 }} />
{status}
{me?.login_required && (
<Stack
direction="row"
alignItems="center"
spacing={1}
onClick={() => void logout()}
sx={{
mt: 1.5,
cursor: "pointer",
color: "text.secondary",
"&:hover": { color: "text.primary" },
}}
>
<LogoutOutlinedIcon fontSize="small" />
<Typography variant="caption">Sign out</Typography>
</Stack>
)}
</Box>
)}
@@ -208,6 +228,14 @@ export default function AppShell({
Kadence
</Typography>
<Box sx={{ width: 120 }}>{status}</Box>
{me?.login_required && (
<Box
onClick={() => void logout()}
sx={{ display: "flex", alignItems: "center", color: "text.secondary", ml: 1 }}
>
<LogoutOutlinedIcon fontSize="small" />
</Box>
)}
</Toolbar>
</AppBar>
)}
+13 -1
View File
@@ -26,6 +26,15 @@ export function setToken(token: string): void {
else localStorage.removeItem(TOKEN_KEY);
}
// The login screen listens here, so a session that expires mid-use drops the
// app back to it instead of showing a generic "backend unreachable" error.
const unauthorizedListeners = new Set<() => void>();
export function onUnauthorized(listener: () => void): () => void {
unauthorizedListeners.add(listener);
return () => unauthorizedListeners.delete(listener);
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
@@ -33,7 +42,10 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = getToken();
if (token) headers.set("X-API-Token", token);
const response = await fetch(path, { ...init, headers });
const response = await fetch(path, { ...init, headers, credentials: "same-origin" });
if (response.status === 401 && !path.startsWith("/api/auth/")) {
unauthorizedListeners.forEach((listener) => listener());
}
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
+81
View File
@@ -0,0 +1,81 @@
/**
* The login gate.
*
* One fetch at start-up decides whether the app behind it is reachable yet.
* When no password or Telegram login is configured, `authenticated` comes
* back `true` immediately — the same friction-free default the API token has
* always had — so nothing changes until Settings → Access is actually set up.
*/
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { api, onUnauthorized } from "./api";
import type { AuthMe } from "./types";
interface AuthContextValue {
me: AuthMe | null;
loading: boolean;
error: string | null;
loginWithPassword: (username: string, password: string) => Promise<void>;
loginWithTelegram: (payload: Record<string, unknown>) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
const Context = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [me, setMe] = useState<AuthMe | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const body = await api.get<AuthMe>("/api/auth/me");
setMe(body);
setError(null);
} catch (exc) {
setError(exc instanceof Error ? exc.message : String(exc));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => onUnauthorized(() => void refresh()), [refresh]);
const loginWithPassword = useCallback(
async (username: string, password: string) => {
await api.post("/api/auth/login", { username, password });
await refresh();
},
[refresh],
);
const loginWithTelegram = useCallback(
async (payload: Record<string, unknown>) => {
await api.post("/api/auth/login/telegram", payload);
await refresh();
},
[refresh],
);
const logout = useCallback(async () => {
await api.post("/api/auth/logout");
await refresh();
}, [refresh]);
const value = useMemo(
() => ({ me, loading, error, loginWithPassword, loginWithTelegram, logout, refresh }),
[me, loading, error, loginWithPassword, loginWithTelegram, logout, refresh],
);
return <Context.Provider value={value}>{children}</Context.Provider>;
}
export function useAuth(): AuthContextValue {
const value = useContext(Context);
if (!value) throw new Error("useAuth outside AuthProvider");
return value;
}
+17
View File
@@ -236,9 +236,26 @@ export interface AppSettings {
};
};
appearance: { theme: "light" | "dark" | "system"; accent: string; compact: boolean };
auth: {
username: string;
password_hash: string;
telegram_login_enabled: boolean;
telegram_owner_id: string;
telegram_bot_username: string;
session_days: number;
};
}
export interface SettingsResponse {
settings: AppSettings;
defaults: { blocked_domains: string[]; blocked_apps: string[] };
}
export interface AuthMe {
authenticated: boolean;
login_required: boolean;
username: string;
password_login_enabled: boolean;
telegram_login_enabled: boolean;
telegram_bot_username: string;
}
+148
View File
@@ -0,0 +1,148 @@
/**
* The gate in front of everything else.
*
* Same editorial language as the rest of the app — warm paper, a serif
* wordmark, a hairline, one filled button — just with nowhere else to go
* until one of the two credentials checks out.
*/
import { useEffect, useRef, useState } from "react";
import { Alert, Box, Button, Divider, Stack, TextField, Typography, useTheme } from "@mui/material";
import { useAuth } from "../lib/auth";
declare global {
interface Window {
onKadenceTelegramAuth?: (user: Record<string, unknown>) => void;
}
}
export default function LoginPage() {
const theme = useTheme();
const { me, loginWithPassword, loginWithTelegram } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const widgetHost = useRef<HTMLDivElement>(null);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true);
setError(null);
try {
await loginWithPassword(username, password);
} catch (exc) {
setError(exc instanceof Error ? exc.message : String(exc));
} finally {
setBusy(false);
}
};
useEffect(() => {
if (!me?.telegram_login_enabled || !me.telegram_bot_username || !widgetHost.current) return;
window.onKadenceTelegramAuth = (user) => {
setError(null);
void loginWithTelegram(user).catch((exc) =>
setError(exc instanceof Error ? exc.message : String(exc)),
);
};
const script = document.createElement("script");
script.src = "https://telegram.org/js/telegram-widget.js?22";
script.async = true;
script.dataset.telegramLogin = me.telegram_bot_username;
script.dataset.size = "large";
script.dataset.radius = "10";
script.dataset.onauth = "onKadenceTelegramAuth(user)";
script.dataset.requestAccess = "write";
widgetHost.current.innerHTML = "";
widgetHost.current.appendChild(script);
}, [me?.telegram_login_enabled, me?.telegram_bot_username, loginWithTelegram]);
return (
<Box
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "background.default",
px: 2,
}}
>
<Box sx={{ width: "100%", maxWidth: 360 }}>
<Stack spacing={0.5} sx={{ mb: 4, textAlign: "center" }}>
<Typography
sx={{
fontFamily: theme.typography.h1.fontFamily,
fontSize: 32,
fontWeight: 500,
letterSpacing: "-0.02em",
lineHeight: 1,
}}
>
Kadence
</Typography>
<Typography variant="caption" color="text.secondary">
find your rhythm
</Typography>
</Stack>
<Typography variant="overline" color="text.secondary" sx={{ display: "block", mb: 1 }}>
Sign in
</Typography>
<Divider sx={{ mb: 3 }} />
{me?.password_login_enabled && (
<Box component="form" onSubmit={submit}>
<Stack spacing={2}>
<TextField
label="Username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoFocus
fullWidth
/>
<TextField
label="Password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
fullWidth
/>
{error && <Alert severity="error">{error}</Alert>}
<Button type="submit" variant="contained" disabled={busy} size="large">
{busy ? "Signing in…" : "Sign in"}
</Button>
</Stack>
</Box>
)}
{me?.password_login_enabled && me?.telegram_login_enabled && (
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ my: 3 }}>
<Divider sx={{ flexGrow: 1 }} />
<Typography variant="caption" color="text.secondary">
or
</Typography>
<Divider sx={{ flexGrow: 1 }} />
</Stack>
)}
{me?.telegram_login_enabled && (
<Stack alignItems="center" spacing={2}>
<Box ref={widgetHost} />
{!me?.password_login_enabled && error && <Alert severity="error">{error}</Alert>}
</Stack>
)}
{!me?.password_login_enabled && !me?.telegram_login_enabled && (
<Alert severity="warning">
No login method is configured yet. Set <code>AUTH_PASSWORD</code> (or a Telegram owner id) in{" "}
<code>.env</code>, or run <code>make set-password</code>.
</Alert>
)}
</Box>
</Box>
);
}
+132
View File
@@ -22,6 +22,7 @@ import {
import { PageHeader, Panel, SectionTitle } from "../components/Layout";
import { api, getToken, setToken } from "../lib/api";
import { useAuth } from "../lib/auth";
import { useSettings } from "../lib/settings";
import type { AppSettings } from "../lib/types";
@@ -453,11 +454,142 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
<code>desktop/</code>; its lists live on the <strong>Blocker</strong> page.
</Alert>
</Section>
<AccessSection patch={patch} onToast={onToast} />
</Stack>
</>
);
}
function AccessSection({
patch,
onToast,
}: {
patch: (change: Patch, message?: string) => Promise<void>;
onToast: (message: string) => void;
}) {
const { settings } = useSettings();
const { me, logout } = useAuth();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [saving, setSaving] = useState(false);
if (!settings) return null;
const auth = settings.auth;
const changePassword = async () => {
setSaving(true);
try {
await api.post("/api/auth/password", {
current_password: currentPassword,
new_password: newPassword,
});
setCurrentPassword("");
setNewPassword("");
onToast("Password changed");
} catch (exc) {
onToast(exc instanceof Error ? exc.message : String(exc));
} finally {
setSaving(false);
}
};
return (
<Section
title="Access"
subtitle="Closes the browser UI behind a login. The desktop app keeps using its own API token."
>
<Panel>
<Typography sx={{ fontWeight: 600, mb: 1.5 }}>Password</Typography>
{auth.password_hash ? (
<>
{me?.login_required ? (
<Stack direction="row" spacing={1.5} sx={{ flexWrap: "wrap", rowGap: 1.5 }}>
<TextField
label="Current password"
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
sx={{ minWidth: 220 }}
/>
<TextField
label="New password"
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
sx={{ minWidth: 220 }}
/>
<Button
variant="outlined"
disabled={saving || newPassword.length < 8}
onClick={changePassword}
>
Change password
</Button>
</Stack>
) : (
<Typography variant="body2" color="text.secondary">
A password is set, but this browser isn't signed in. Sign out and back in to rotate it
here, or run <code>make set-password</code> on the server.
</Typography>
)}
<Stack direction="row" spacing={2} sx={{ mt: 1.5 }} alignItems="center">
<Chip size="small" color="success" variant="outlined" label={`on · ${auth.username}`} />
<Button size="small" onClick={() => void logout()}>
Sign out
</Button>
</Stack>
</>
) : (
<Typography variant="body2" color="text.secondary">
Not set — the browser UI is open to anyone who can reach this address. Set{" "}
<code>AUTH_PASSWORD</code> in <code>.env</code> (and restart), or run{" "}
<code>make set-password</code> on the server, then reload this page.
</Typography>
)}
<Field
label="Username"
value={auth.username}
onCommit={(value) => void patch({ auth: { username: value } })}
/>
</Panel>
<Panel sx={{ mt: 2 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: auth.telegram_login_enabled ? 1.5 : 0 }}>
<Typography sx={{ fontWeight: 600, flexGrow: 1 }}>Telegram login</Typography>
{auth.telegram_login_enabled && auth.telegram_owner_id && (
<Chip size="small" color="success" variant="outlined" label="on" />
)}
<Switch
checked={auth.telegram_login_enabled}
onChange={(event) => void patch({ auth: { telegram_login_enabled: event.target.checked } })}
/>
</Stack>
{auth.telegram_login_enabled && (
<Stack spacing={1.5} direction="row" sx={{ flexWrap: "wrap", rowGap: 1.5 }}>
<Field
label="Your Telegram user id"
value={auth.telegram_owner_id}
placeholder="e.g. from @userinfobot"
onCommit={(value) => void patch({ auth: { telegram_owner_id: value } })}
/>
<Field
label="Bot username (no @)"
value={auth.telegram_bot_username}
placeholder={settings.notifications.telegram.bot_token ? "" : "needs a bot token above"}
onCommit={(value) => void patch({ auth: { telegram_bot_username: value } })}
/>
</Stack>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>
Needs the bot token from the Telegram section above, plus `/setdomain` registered with
@BotFather for this site's HTTPS domain.
</Typography>
</Panel>
</Section>
);
}
function Section({
title,
subtitle,