feat: User-Verwaltung mit Login, Sessions und Owner-Filter
Backend: - User-Modell (sqlmodel) mit bcrypt-Passwort-Hash und is_admin-Flag - Job.owner_id (FK auf user.id), per Migration nachgerüstet (SQLite ALTER) - Starlette SessionMiddleware mit signiertem Cookie (SESSION_SECRET) - /api/auth: login / logout / me / needs-setup / setup (Initial-Admin) - /api/admin: User-CRUD + Passwort-Reset (Admin-only); letzter Admin / eigener Account vor Löschung geschützt - /api/jobs: Owner-Filter (Admin sieht alle inkl. owner_username), 404 statt 403 bei fremden Jobs (kein Existenz-Leak) Frontend: - Auth-Overlay mit Login bzw. Erst-Setup-Modus (auto-Detection) - Header zeigt User + Logout, Admin-Badge bei Admin-Konten - Admin-Karte: User anlegen, Passwort zurücksetzen, Löschen (mit Confirm) - 401 → Login-Overlay (auch bei XHR-Upload) Deploy: - install.sh generiert SESSION_SECRET einmalig per python-secrets - .env wird auf chmod 600 gesetzt - Erstaufruf zeigt automatisch das Setup-Formular; bestehende Jobs werden dem ersten Admin zugeordnet
This commit is contained in:
+239
-22
@@ -1,3 +1,4 @@
|
||||
// ─── DOM-Refs ──────────────────────────────────────────────────────────────
|
||||
const form = document.getElementById("upload-form");
|
||||
const titleInput = document.getElementById("title");
|
||||
const audioInput = document.getElementById("audio");
|
||||
@@ -8,6 +9,154 @@ const uploadMsg = document.getElementById("upload-msg");
|
||||
const jobsBody = document.getElementById("jobs-body");
|
||||
const macStatus = document.getElementById("mac-status");
|
||||
|
||||
const appMain = document.getElementById("app");
|
||||
const authOverlay = document.getElementById("auth-overlay");
|
||||
const authTitle = document.getElementById("auth-title");
|
||||
const authInfo = document.getElementById("auth-info");
|
||||
const authForm = document.getElementById("auth-form");
|
||||
const authUsername = document.getElementById("auth-username");
|
||||
const authPassword = document.getElementById("auth-password");
|
||||
const authSubmit = document.getElementById("auth-submit");
|
||||
const authMsg = document.getElementById("auth-msg");
|
||||
const userBar = document.getElementById("user-bar");
|
||||
const userName = document.getElementById("user-name");
|
||||
const userAdminBadge = document.getElementById("user-admin-badge");
|
||||
const logoutBtn = document.getElementById("logout-btn");
|
||||
|
||||
const adminCard = document.getElementById("admin-card");
|
||||
const userForm = document.getElementById("user-form");
|
||||
const newUsername = document.getElementById("new-username");
|
||||
const newPassword = document.getElementById("new-password");
|
||||
const newIsAdmin = document.getElementById("new-is-admin");
|
||||
const userMsg = document.getElementById("user-msg");
|
||||
const usersBody = document.getElementById("users-body");
|
||||
|
||||
let currentUser = null; // { id, username, is_admin }
|
||||
let mode = "login"; // "login" | "setup"
|
||||
|
||||
const DOWNLOADS = [
|
||||
{ kind: "docx", label: "DOCX", title: "Sitzungsprotokoll als Word-Dokument" },
|
||||
{ kind: "protocol", label: "Protokoll", title: "Strukturiertes Protokoll (JSON)" },
|
||||
{ kind: "summary", label: "Summary", title: "Zusammenfassung (JSON)" },
|
||||
{ kind: "transcript", label: "Transkript", title: "Rohes Transkript (JSON)" },
|
||||
];
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
const d = new Date(s);
|
||||
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" });
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const r = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
||||
...opts,
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
// ─── Auth-Flow ─────────────────────────────────────────────────────────────
|
||||
async function bootstrap() {
|
||||
const me = await api("/api/auth/me");
|
||||
if (me.ok) {
|
||||
currentUser = await me.json();
|
||||
enterApp();
|
||||
return;
|
||||
}
|
||||
// Nicht eingeloggt — Setup nötig?
|
||||
const setupCheck = await api("/api/auth/needs-setup");
|
||||
const setupData = await setupCheck.json();
|
||||
if (setupData.needs_setup) {
|
||||
showAuth("setup");
|
||||
} else {
|
||||
showAuth("login");
|
||||
}
|
||||
}
|
||||
|
||||
function showAuth(which) {
|
||||
mode = which;
|
||||
authMsg.textContent = "";
|
||||
authMsg.className = "msg";
|
||||
authForm.reset();
|
||||
if (which === "setup") {
|
||||
authTitle.textContent = "Initialen Admin anlegen";
|
||||
authInfo.textContent =
|
||||
"Noch kein Benutzer vorhanden. Lege jetzt den ersten Admin an. Bestehende Jobs werden diesem Konto zugeordnet.";
|
||||
authInfo.classList.remove("hidden");
|
||||
authPassword.autocomplete = "new-password";
|
||||
authPassword.minLength = 8;
|
||||
authSubmit.textContent = "Admin anlegen";
|
||||
} else {
|
||||
authTitle.textContent = "Anmelden";
|
||||
authInfo.classList.add("hidden");
|
||||
authInfo.textContent = "";
|
||||
authPassword.autocomplete = "current-password";
|
||||
authPassword.minLength = 1;
|
||||
authSubmit.textContent = "Anmelden";
|
||||
}
|
||||
authOverlay.classList.remove("hidden");
|
||||
appMain.classList.add("hidden");
|
||||
userBar.classList.add("hidden");
|
||||
setTimeout(() => authUsername.focus(), 50);
|
||||
}
|
||||
|
||||
function enterApp() {
|
||||
authOverlay.classList.add("hidden");
|
||||
appMain.classList.remove("hidden");
|
||||
userBar.classList.remove("hidden");
|
||||
userName.textContent = currentUser.username;
|
||||
userAdminBadge.classList.toggle("hidden", !currentUser.is_admin);
|
||||
adminCard.classList.toggle("hidden", !currentUser.is_admin);
|
||||
loadJobs();
|
||||
if (currentUser.is_admin) loadUsers();
|
||||
}
|
||||
|
||||
authForm.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
authSubmit.disabled = true;
|
||||
authMsg.textContent = "";
|
||||
authMsg.className = "msg";
|
||||
const body = JSON.stringify({
|
||||
username: authUsername.value.trim(),
|
||||
password: authPassword.value,
|
||||
});
|
||||
const url = mode === "setup" ? "/api/auth/setup" : "/api/auth/login";
|
||||
try {
|
||||
const r = await api(url, { method: "POST", body });
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
authMsg.textContent = data.detail || "Fehler bei der Anmeldung";
|
||||
authMsg.className = "msg err";
|
||||
return;
|
||||
}
|
||||
currentUser = data;
|
||||
if (mode === "setup" && typeof data.migrated_jobs === "number" && data.migrated_jobs > 0) {
|
||||
console.info(`${data.migrated_jobs} Bestand-Jobs wurden zugeordnet.`);
|
||||
}
|
||||
enterApp();
|
||||
} catch (e) {
|
||||
authMsg.textContent = "Netzwerkfehler";
|
||||
authMsg.className = "msg err";
|
||||
} finally {
|
||||
authSubmit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
await api("/api/auth/logout", { method: "POST" });
|
||||
currentUser = null;
|
||||
jobsBody.innerHTML = "";
|
||||
showAuth("login");
|
||||
});
|
||||
|
||||
// ─── Mac-Status ────────────────────────────────────────────────────────────
|
||||
async function checkMac() {
|
||||
try {
|
||||
const r = await fetch("/api/mac/health");
|
||||
@@ -27,24 +176,7 @@ async function checkMac() {
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
const d = new Date(s);
|
||||
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" });
|
||||
}
|
||||
|
||||
const DOWNLOADS = [
|
||||
{ kind: "docx", label: "DOCX", title: "Sitzungsprotokoll als Word-Dokument" },
|
||||
{ kind: "protocol", label: "Protokoll", title: "Strukturiertes Protokoll (JSON)" },
|
||||
{ kind: "summary", label: "Summary", title: "Zusammenfassung (JSON)" },
|
||||
{ kind: "transcript", label: "Transkript", title: "Rohes Transkript (JSON)" },
|
||||
];
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[c]));
|
||||
}
|
||||
|
||||
// ─── Jobs ──────────────────────────────────────────────────────────────────
|
||||
function row(job) {
|
||||
const tr = document.createElement("tr");
|
||||
const title = job.title || job.original_name;
|
||||
@@ -65,6 +197,10 @@ function row(job) {
|
||||
dlCell.appendChild(a);
|
||||
});
|
||||
|
||||
const ownerLine = currentUser?.is_admin && job.owner_username
|
||||
? `<br><small class="muted">von ${escapeHtml(job.owner_username)}</small>`
|
||||
: "";
|
||||
|
||||
const statusCell = `
|
||||
<span class="status-pill status-${job.status}">${escapeHtml(job.status)}</span>
|
||||
${job.error ? `<div class="status-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
|
||||
@@ -72,7 +208,7 @@ function row(job) {
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="col-id">#${job.id}</td>
|
||||
<td class="col-title">${escapeHtml(title)}<br><small class="muted">${escapeHtml(job.original_name)}</small></td>
|
||||
<td class="col-title">${escapeHtml(title)}<br><small class="muted">${escapeHtml(job.original_name)}</small>${ownerLine}</td>
|
||||
<td class="col-status">${statusCell}</td>
|
||||
<td class="col-progress"><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div><small class="muted">${job.progress}%</small></td>
|
||||
<td class="col-updated"><small class="muted">${fmtDate(job.updated_at)}</small></td>
|
||||
@@ -82,8 +218,10 @@ function row(job) {
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
if (!currentUser) return;
|
||||
try {
|
||||
const r = await fetch("/api/jobs");
|
||||
const r = await api("/api/jobs");
|
||||
if (r.status === 401) { showAuth("login"); return; }
|
||||
const jobs = await r.json();
|
||||
jobsBody.innerHTML = "";
|
||||
if (!jobs.length) {
|
||||
@@ -111,6 +249,7 @@ form.addEventListener("submit", (ev) => {
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/jobs");
|
||||
xhr.withCredentials = true;
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const p = Math.round((e.loaded / e.total) * 100);
|
||||
@@ -120,6 +259,7 @@ form.addEventListener("submit", (ev) => {
|
||||
xhr.onload = () => {
|
||||
submitBtn.disabled = false;
|
||||
uploadProgress.classList.add("hidden");
|
||||
if (xhr.status === 401) { showAuth("login"); return; }
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
const j = JSON.parse(xhr.responseText);
|
||||
uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`;
|
||||
@@ -142,7 +282,84 @@ form.addEventListener("submit", (ev) => {
|
||||
xhr.send(fd);
|
||||
});
|
||||
|
||||
// ─── Admin: User-Verwaltung ────────────────────────────────────────────────
|
||||
async function loadUsers() {
|
||||
if (!currentUser?.is_admin) return;
|
||||
try {
|
||||
const r = await api("/api/admin/users");
|
||||
if (!r.ok) return;
|
||||
const users = await r.json();
|
||||
usersBody.innerHTML = "";
|
||||
users.forEach((u) => {
|
||||
const tr = document.createElement("tr");
|
||||
const isMe = u.id === currentUser.id;
|
||||
tr.innerHTML = `
|
||||
<td>#${u.id}</td>
|
||||
<td><strong>${escapeHtml(u.username)}</strong>${isMe ? ' <span class="muted">(du)</span>' : ""}</td>
|
||||
<td>${u.is_admin ? '<span class="badge admin-badge">Admin</span>' : '<span class="muted">User</span>'}</td>
|
||||
<td><small class="muted">${fmtDate(u.created_at)}</small></td>
|
||||
<td class="user-actions">
|
||||
<button type="button" class="btn-link reset-pw" data-id="${u.id}" data-name="${escapeHtml(u.username)}">Passwort zurücksetzen</button>
|
||||
${isMe ? "" : `<button type="button" class="btn-link btn-danger del-user" data-id="${u.id}" data-name="${escapeHtml(u.username)}">Löschen</button>`}
|
||||
</td>
|
||||
`;
|
||||
usersBody.appendChild(tr);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
userForm.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
userMsg.textContent = "";
|
||||
userMsg.className = "msg";
|
||||
const body = JSON.stringify({
|
||||
username: newUsername.value.trim(),
|
||||
password: newPassword.value,
|
||||
is_admin: newIsAdmin.checked,
|
||||
});
|
||||
const r = await api("/api/admin/users", { method: "POST", body });
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
userMsg.textContent = data.detail || "Fehler beim Anlegen";
|
||||
userMsg.className = "msg err";
|
||||
return;
|
||||
}
|
||||
userMsg.textContent = `Benutzer '${data.username}' angelegt.`;
|
||||
userMsg.className = "msg ok";
|
||||
userForm.reset();
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
usersBody.addEventListener("click", async (ev) => {
|
||||
const t = ev.target;
|
||||
if (t.matches(".del-user")) {
|
||||
if (!confirm(`Benutzer '${t.dataset.name}' wirklich löschen?\nDie Jobs des Benutzers bleiben erhalten (gehören dann keinem mehr).`)) return;
|
||||
const r = await api(`/api/admin/users/${t.dataset.id}`, { method: "DELETE" });
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
alert(d.detail || "Löschen fehlgeschlagen");
|
||||
}
|
||||
loadUsers();
|
||||
} else if (t.matches(".reset-pw")) {
|
||||
const pw = prompt(`Neues Passwort für '${t.dataset.name}' (min. 8 Zeichen):`);
|
||||
if (!pw) return;
|
||||
if (pw.length < 8) { alert("Passwort muss min. 8 Zeichen haben"); return; }
|
||||
const r = await api(`/api/admin/users/${t.dataset.id}/password`, {
|
||||
method: "POST", body: JSON.stringify({ password: pw }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
alert(d.detail || "Zurücksetzen fehlgeschlagen");
|
||||
} else {
|
||||
alert("Passwort gesetzt.");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Init ──────────────────────────────────────────────────────────────────
|
||||
bootstrap();
|
||||
checkMac();
|
||||
loadJobs();
|
||||
setInterval(loadJobs, 3000);
|
||||
setInterval(() => { if (currentUser) loadJobs(); }, 3000);
|
||||
setInterval(checkMac, 15000);
|
||||
|
||||
@@ -8,18 +8,50 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Voice-Agent</h1>
|
||||
<p class="sub">Sitzungsaufnahmen automatisch transkribieren und protokollieren</p>
|
||||
<div id="mac-status" class="badge">Mac-Backend: prüfe …</div>
|
||||
<div class="header-row">
|
||||
<div class="header-left">
|
||||
<h1>Voice-Agent</h1>
|
||||
<p class="sub">Sitzungsaufnahmen automatisch transkribieren und protokollieren</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div id="mac-status" class="badge">Mac-Backend: prüfe …</div>
|
||||
<div id="user-bar" class="user-bar hidden">
|
||||
<span class="muted">Angemeldet als</span>
|
||||
<strong id="user-name"></strong>
|
||||
<span id="user-admin-badge" class="badge admin-badge hidden">Admin</span>
|
||||
<button type="button" id="logout-btn" class="btn-link">Abmelden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Auth-Overlay (Login oder Erst-Setup) -->
|
||||
<div id="auth-overlay" class="overlay hidden">
|
||||
<div class="auth-card">
|
||||
<h2 id="auth-title">Anmelden</h2>
|
||||
<p id="auth-info" class="muted hidden"></p>
|
||||
<form id="auth-form">
|
||||
<label>
|
||||
Benutzername
|
||||
<input type="text" id="auth-username" autocomplete="username" required />
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input type="password" id="auth-password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<button type="submit" id="auth-submit">Anmelden</button>
|
||||
</form>
|
||||
<p id="auth-msg" class="msg"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main id="app" class="hidden">
|
||||
<section class="card">
|
||||
<h2>Neue Aufnahme hochladen</h2>
|
||||
<form id="upload-form">
|
||||
<label>
|
||||
Sitzungstitel (optional)
|
||||
<input type="text" name="title" id="title" placeholder="z. B. Monatsbesprechung 13.05.2026" />
|
||||
<input type="text" name="title" id="title" placeholder="z. B. Monatsbesprechung" />
|
||||
</label>
|
||||
<label>
|
||||
Audiodatei (MP3, WAV, M4A, MP4, OGG, FLAC)
|
||||
@@ -52,6 +84,39 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Nur sichtbar für Admins -->
|
||||
<section id="admin-card" class="card hidden">
|
||||
<h2>Benutzer verwalten</h2>
|
||||
<form id="user-form" class="user-form">
|
||||
<label>
|
||||
Benutzername
|
||||
<input type="text" id="new-username" autocomplete="off" required pattern="[A-Za-z0-9._@\-]+" />
|
||||
</label>
|
||||
<label>
|
||||
Passwort (min. 8 Zeichen)
|
||||
<input type="password" id="new-password" autocomplete="new-password" required minlength="8" />
|
||||
</label>
|
||||
<label class="inline-check">
|
||||
<input type="checkbox" id="new-is-admin" />
|
||||
Admin-Rechte
|
||||
</label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
<p id="user-msg" class="msg"></p>
|
||||
<table id="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Benutzer</th>
|
||||
<th>Rolle</th>
|
||||
<th>Angelegt</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
|
||||
@@ -162,3 +162,93 @@ th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: upp
|
||||
opacity: .55;
|
||||
}
|
||||
footer { text-align: center; padding: 24px 16px 32px; }
|
||||
|
||||
/* ── Header-Layout ─────────────────────────────────────────────── */
|
||||
.header-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header-left { text-align: left; }
|
||||
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
|
||||
.user-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.user-bar strong { color: var(--text); }
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 0;
|
||||
padding: 2px 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.btn-link:hover { color: var(--accent-hover); }
|
||||
.btn-link.btn-danger { color: var(--err); }
|
||||
.admin-badge {
|
||||
background: rgba(79,140,255,.18);
|
||||
color: var(--accent);
|
||||
border-color: rgba(79,140,255,.45);
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: .5px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
/* ── Auth-Overlay ──────────────────────────────────────────────── */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 17, 21, .92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 20px;
|
||||
}
|
||||
.auth-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 28px 28px 22px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.4);
|
||||
}
|
||||
.auth-card h2 { margin: 0 0 8px; font-size: 20px; }
|
||||
.auth-card #auth-info { font-size: 13px; margin: 0 0 14px; }
|
||||
.auth-card form { display: grid; gap: 12px; }
|
||||
.auth-card button { justify-self: stretch; }
|
||||
|
||||
/* ── Admin: Benutzer-Verwaltung ────────────────────────────────── */
|
||||
.user-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
.user-form label { font-size: 13px; }
|
||||
.inline-check {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
.inline-check input { margin: 0; }
|
||||
#users-table .user-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.user-form { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user