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