Archived
Restore the app files (prior commit mis-scoped its tar)
build-and-publish / build (push) Failing after 8s
build-and-publish / build (push) Failing after 8s
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { getMe } from '../api';
|
||||
import type { MeInfo } from '../types';
|
||||
|
||||
interface AuthState {
|
||||
me: MeInfo | null;
|
||||
loading: boolean;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState>({ me: null, loading: true, refresh: () => {} });
|
||||
|
||||
/** This is a public site — /api/me is open and simply reports whether an admin is signed in. */
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [me, setMe] = useState<MeInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
getMe().then(setMe).catch(() => setMe(null)).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
return <AuthContext.Provider value={{ me, loading, refresh }}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Shared HTTP core (canonical copy: platform/frontend-template/src/lib/http.ts). Same-origin fetch to
|
||||
// /api, CSRF from the XSRF-TOKEN cookie, and 401 -> Authentik login redirect (only admin routes are gated).
|
||||
|
||||
export interface HttpOptions {
|
||||
loginUrl?: string;
|
||||
base?: string;
|
||||
}
|
||||
|
||||
function csrfHeader(): Record<string, string> {
|
||||
const m = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
|
||||
return m ? { 'X-XSRF-TOKEN': decodeURIComponent(m[1]) } : {};
|
||||
}
|
||||
|
||||
export function createApi(opts: HttpOptions = {}) {
|
||||
const loginUrl = opts.loginUrl ?? '/oauth2/authorization/authentik';
|
||||
const base = opts.base ?? '/api';
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(base + path, {
|
||||
...init,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...csrfHeader(), ...(init?.headers ?? {}) },
|
||||
});
|
||||
if (res.status === 401) {
|
||||
window.location.href = loginUrl;
|
||||
throw new Error('unauthenticated');
|
||||
}
|
||||
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`);
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
}
|
||||
|
||||
return {
|
||||
get: <T>(path: string) => req<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
req<T>(path, { method: 'POST', body: body != null ? JSON.stringify(body) : undefined }),
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
req<T>(path, { method: 'PUT', body: body != null ? JSON.stringify(body) : undefined }),
|
||||
del: <T>(path: string) => req<T>(path, { method: 'DELETE' }),
|
||||
login(): void {
|
||||
window.location.href = loginUrl;
|
||||
},
|
||||
logout(): void {
|
||||
// Full-page form POST so the browser follows the RP-initiated logout redirect chain.
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/logout';
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Api = ReturnType<typeof createApi>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Minimal data-loading helper: runs `fn` on mount / when `deps` change. */
|
||||
export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fn()
|
||||
.then((d) => { if (alive) { setData(d); setLoading(false); } })
|
||||
.catch((e) => { if (alive) { setError(String(e)); setLoading(false); } });
|
||||
return () => { alive = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user