add auth
This commit is contained in:
22 files changed
+1034
-25
No files matched your search
+36
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user