cms: Autoren-Verwaltung (Admin), Cover-Upload, einheitliche Feldhöhen
- Admin-only Seite „Autor:innen": Nutzer anlegen/Passwort setzen/löschen via GoTrue-Admin-API (/api/users, requireAdmin). /api/me liefert isAdmin → Nav zeigt den Punkt nur Admins. - Cover-Bild: Upload-Knopf + Thumbnail (Bilder im Beitrag gingen schon über den WYSIWYG-Editor). - Editor-Metazeile: einzeilige Felder + Dropdowns einheitlich 38px hoch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+79
-2
@@ -72,13 +72,14 @@ function Dashboard({ email }) {
|
||||
const [current, setCurrent] = useState(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [view, setView] = useState('content');
|
||||
const [me, setMe] = useState(null);
|
||||
const [msg, setMsg] = useState(null);
|
||||
|
||||
async function refresh() {
|
||||
try { setEntries(await api.list()); }
|
||||
catch (e) { setMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
useEffect(() => { refresh(); }, []);
|
||||
useEffect(() => { refresh(); api.getMe().then(setMe).catch(() => {}); }, []);
|
||||
useEffect(() => { if (!msg) return; const t = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(t); }, [msg]);
|
||||
|
||||
async function open(entry) {
|
||||
@@ -99,6 +100,7 @@ function Dashboard({ email }) {
|
||||
<nav className="nav">
|
||||
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
||||
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
||||
{me?.isAdmin && <button className={view === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
|
||||
</nav>
|
||||
<span className="spacer" />
|
||||
<span className="who">{email}</span>
|
||||
@@ -108,6 +110,8 @@ function Dashboard({ email }) {
|
||||
<div className="body">
|
||||
{view === 'profile' ? (
|
||||
<Profile onMsg={setMsg} />
|
||||
) : view === 'users' ? (
|
||||
<Users onMsg={setMsg} currentEmail={me?.email} />
|
||||
) : (
|
||||
<>
|
||||
<aside>
|
||||
@@ -154,8 +158,18 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const editorRef = useRef(null);
|
||||
const dragging = useRef(false);
|
||||
const coverIn = useRef(null);
|
||||
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
|
||||
|
||||
async function pickCover(ev) {
|
||||
const file = ev.target.files?.[0]; ev.target.value = '';
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try { const { url } = await api.upload(file); setF((p) => ({ ...p, cover_image: url })); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// Ziehbarer Trenner Editor ↔ Vorschau.
|
||||
useEffect(() => {
|
||||
function move(e) {
|
||||
@@ -263,7 +277,14 @@ function Editor({ initial, onSaved, onMsg }) {
|
||||
|
||||
<label>Kurztext (summary)<input value={f.summary} onChange={set('summary')} /></label>
|
||||
<div className="row">
|
||||
<label>Cover-Bild<input value={f.cover_image} onChange={set('cover_image')} placeholder="/images/…jpg" /></label>
|
||||
<label>Cover-Bild
|
||||
<div className="cover-row">
|
||||
<input value={f.cover_image} onChange={set('cover_image')} placeholder="/images/…jpg" />
|
||||
<button type="button" onClick={() => coverIn.current?.click()} disabled={busy}>Hochladen</button>
|
||||
<input ref={coverIn} type="file" accept="image/*" hidden onChange={pickCover} />
|
||||
{f.cover_image && <span className="cover-thumb" style={{ backgroundImage: `url(${f.cover_image})` }} />}
|
||||
</div>
|
||||
</label>
|
||||
<label>Externer Link<input value={f.external} onChange={set('external')} placeholder="https://…" /></label>
|
||||
</div>
|
||||
<label>Autor:innen (E-Mails, Komma — für gemeinsamen Zugriff)
|
||||
@@ -371,6 +392,62 @@ function Profile({ onMsg }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
||||
function Users({ onMsg, currentEmail }) {
|
||||
const [list, setList] = useState(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function refresh() {
|
||||
try { setList(await api.listUsers()); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
useEffect(() => { refresh(); }, []);
|
||||
|
||||
async function create(e) {
|
||||
e.preventDefault(); setBusy(true);
|
||||
try { await api.createUser(email, password); onMsg({ type: 'ok', text: 'Autor:in angelegt.' }); setEmail(''); setPassword(''); refresh(); }
|
||||
catch (err) { onMsg({ type: 'err', text: err.message }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function remove(u) {
|
||||
if (!confirm(`${u.email} löschen?`)) return;
|
||||
try { await api.deleteUser(u.id); refresh(); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
async function reset(u) {
|
||||
const pw = prompt(`Neues Passwort für ${u.email}:`);
|
||||
if (!pw) return;
|
||||
try { await api.setPassword(u.id, pw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
|
||||
if (!list) return <div className="empty">…</div>;
|
||||
return (
|
||||
<div className="profile">
|
||||
<div className="profile-card">
|
||||
<h2>Autor:innen</h2>
|
||||
<form className="userform" onSubmit={create}>
|
||||
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
<input type="text" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
<button className="primary" disabled={busy}>Anlegen</button>
|
||||
</form>
|
||||
<ul className="userlist">
|
||||
{list.map((u) => (
|
||||
<li key={u.id}>
|
||||
<span className="t">{u.email}{u.isAdmin && <span className="status live">Admin</span>}</span>
|
||||
<button onClick={() => reset(u)}>Passwort</button>
|
||||
{u.email !== currentEmail && <button onClick={() => remove(u)}>Löschen</button>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="muted who-mail">Admin-Rechte werden über <code>ADMIN_EMAILS</code> in der .env vergeben (nicht hier).</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
|
||||
function fromRead(r) {
|
||||
const fm = r.frontmatter || {};
|
||||
|
||||
@@ -43,4 +43,9 @@ export const api = {
|
||||
upload: uploadFile,
|
||||
getProfile: () => call('/profile'),
|
||||
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
|
||||
getMe: () => call('/me'),
|
||||
listUsers: () => call('/users'),
|
||||
createUser: (email, password) => call('/users', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
|
||||
deleteUser: (id) => call(`/users/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
@@ -41,6 +41,10 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; colo
|
||||
|
||||
/* ── Inputs / Buttons (Pill) ── */
|
||||
input, select, textarea { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 9px 11px; width: 100%; }
|
||||
/* Einheitliche Höhe für einzeilige Felder (Dropdowns = Textfelder) */
|
||||
.fields label:not(.big) input, .fields select, .login input, .profile-card input, .userform input {
|
||||
height: 38px; padding: 0 11px;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent-soft); box-shadow: 0 0 0 3px rgba(181,74,44,.12); }
|
||||
button { background: var(--panel); border: 1px solid var(--line); border-radius: var(--pill); padding: 8px 16px; cursor: pointer; font-weight: 500; transition: .12s; white-space: nowrap; }
|
||||
button:hover { border-color: var(--accent-soft); }
|
||||
@@ -112,9 +116,24 @@ label.check { flex-direction: row; align-items: center; gap: 7px; white-space: n
|
||||
label.check input { width: auto; }
|
||||
label.big input { font-family: var(--serif); font-size: 23px; font-weight: 600; padding: 11px 13px; }
|
||||
.colorpick { display: flex; align-items: center; gap: 8px; }
|
||||
.colorpick .swatch { width: 22px; height: 22px; border-radius: 6px; border: 1px solid rgba(0,0,0,.15); flex: none; }
|
||||
.colorpick .swatch { width: 38px; height: 38px; border-radius: 8px; border: 1px solid rgba(0,0,0,.15); flex: none; }
|
||||
.colorpick select { flex: 1; }
|
||||
|
||||
/* Cover-Upload */
|
||||
.cover-row { display: flex; align-items: center; gap: 8px; }
|
||||
.cover-row input { flex: 1; }
|
||||
.cover-row button { height: 38px; flex: none; }
|
||||
.cover-thumb { width: 38px; height: 38px; border-radius: 8px; border: 1px solid var(--line); background: center/cover no-repeat; flex: none; }
|
||||
|
||||
/* Autor:innen-Verwaltung */
|
||||
.userform { display: flex; gap: 8px; }
|
||||
.userform input { flex: 1; }
|
||||
.userlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.userlist li { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.userlist .t { flex: 1; display: flex; align-items: center; gap: 9px; font-family: var(--serif); }
|
||||
.userlist button { padding: 5px 12px; font-size: 13px; }
|
||||
.userlist .status { padding: 2px 9px; }
|
||||
|
||||
/* WYSIWYG-Editor füllt den meisten Platz */
|
||||
.rich { flex: 1; min-height: 460px; display: flex; flex-direction: column; }
|
||||
.rich-host { flex: 1; min-height: 0; }
|
||||
|
||||
@@ -19,3 +19,9 @@ export async function requireAuth(c, next) {
|
||||
c.set('isAdmin', ADMINS.includes(email));
|
||||
await next();
|
||||
}
|
||||
|
||||
// Nur Admins (nach requireAuth einsetzen).
|
||||
export async function requireAdmin(c, next) {
|
||||
if (!c.get('isAdmin')) return c.json({ error: 'Nur für Admins' }, 403);
|
||||
await next();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import preview from './routes/preview.js';
|
||||
import publish from './routes/publish.js';
|
||||
import upload from './routes/upload.js';
|
||||
import profile from './routes/profile.js';
|
||||
import users from './routes/users.js';
|
||||
import { requireAuth } from './auth.js';
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
@@ -19,11 +20,13 @@ const app = new Hono();
|
||||
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
|
||||
// Alles unter /api/* (ausser /health oben) braucht ein gültiges Supabase-Token.
|
||||
app.use('/api/*', requireAuth);
|
||||
app.get('/api/me', (c) => c.json({ email: c.get('email'), isAdmin: c.get('isAdmin') }));
|
||||
app.route('/api/content', content);
|
||||
app.route('/api/preview', preview);
|
||||
app.route('/api/publish', publish);
|
||||
app.route('/api/upload', upload);
|
||||
app.route('/api/profile', profile);
|
||||
app.route('/api/users', users);
|
||||
|
||||
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
|
||||
app.get('/admin', (c) => c.redirect('/admin/'));
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { requireAdmin } from '../auth.js';
|
||||
|
||||
// Autoren-/Nutzerverwaltung über die GoTrue-Admin-API (Service-Key). Nur Admins.
|
||||
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
const users = new Hono();
|
||||
users.use('*', requireAdmin);
|
||||
|
||||
users.get('/', async (c) => {
|
||||
const { data, error } = await supabase.auth.admin.listUsers();
|
||||
if (error) return c.json({ error: error.message }, 500);
|
||||
const list = (data?.users || []).map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
created_at: u.created_at,
|
||||
isAdmin: ADMINS.includes((u.email || '').toLowerCase()),
|
||||
}));
|
||||
return c.json(list);
|
||||
});
|
||||
|
||||
users.post('/', async (c) => {
|
||||
const { email, password } = await c.req.json();
|
||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||
const { data, error } = await supabase.auth.admin.createUser({ email, password, email_confirm: true });
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true, id: data.user.id });
|
||||
});
|
||||
|
||||
users.put('/:id', async (c) => {
|
||||
const { password } = await c.req.json();
|
||||
if (!password) return c.json({ error: 'Passwort nötig' }, 400);
|
||||
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), { password });
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
users.delete('/:id', async (c) => {
|
||||
const { error } = await supabase.auth.admin.deleteUser(c.req.param('id'));
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default users;
|
||||
Reference in New Issue
Block a user