Phase 2: multi-tab dashboard shell + user management
- Restructure /admin into a (dashboard) route group with a sidebar shell (Galerija / Storitve / Novice / Kontakt / Uporabniki) and active-tab state - Hide the public site header/footer on all /admin routes - Centralize auth in hooks.server.ts so page loads, form actions AND endpoints are all gated (layout load guards don't cover actions) - Users section (admin-only): add user, change role, reset password, delete; safeguards prevent self-delete and removing the last admin; server-side role checks (editors get 403 even if UI hidden) - Login redirects already-authed users; logout moved to /admin/logout endpoint - Gallery management moved under /admin/gallery; shared .admin-* styles in app.css - Fix flexbox overflow in the dashboard main column (min-width:0, box-sizing) - Placeholder tabs for Services/News/Contact (Phases 3-5) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6bc5fffda2
commit
22af32c722
82
src/app.css
82
src/app.css
|
|
@ -21,3 +21,85 @@ body {
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---- Shared admin dashboard UI ---- */
|
||||
.admin-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2em;
|
||||
}
|
||||
.admin-section h1 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.admin-section h2 {
|
||||
margin: 0 0 0.3em;
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.admin-card {
|
||||
background: #11161a;
|
||||
border: 1px solid rgba(229, 228, 234, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 1.4em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9em;
|
||||
}
|
||||
.admin-card label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35em;
|
||||
font-size: 0.9em;
|
||||
color: #c7c9cf;
|
||||
}
|
||||
.admin-input {
|
||||
background: #0a0d0f;
|
||||
border: 1px solid rgba(229, 228, 234, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.6em 0.7em;
|
||||
color: #e5e4ea;
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.admin-btn {
|
||||
background: #42af38;
|
||||
color: #06210a;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55em 1.1em;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
font-size: 0.95em;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.admin-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.admin-btn-danger {
|
||||
background: rgba(220, 40, 40, 0.9);
|
||||
color: #fff;
|
||||
}
|
||||
.admin-btn-ghost {
|
||||
background: transparent;
|
||||
color: #e5e4ea;
|
||||
border: 1px solid rgba(229, 228, 234, 0.3);
|
||||
}
|
||||
.admin-msg {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: #42af38;
|
||||
}
|
||||
.admin-error {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
.admin-notice {
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Handle } from '@sveltejs/kit';
|
||||
import { redirect, type Handle } from '@sveltejs/kit';
|
||||
import { dev } from '$app/environment';
|
||||
import { SESSION_COOKIE, validateSessionToken } from '$lib/server/auth';
|
||||
|
||||
|
|
@ -28,5 +28,13 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||
event.locals.session = null;
|
||||
}
|
||||
|
||||
// Protect the whole dashboard here (except the login page) so that page
|
||||
// loads, form actions AND endpoints are all gated — layout `load` guards do
|
||||
// not run before form actions.
|
||||
const { pathname } = event.url;
|
||||
if (pathname.startsWith('/admin') && pathname !== '/admin/login' && !event.locals.user) {
|
||||
throw redirect(303, '/admin/login');
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { count, eq } from 'drizzle-orm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from './db';
|
||||
import { users, type PublicUser } from './db/schema';
|
||||
import { hashPassword } from './auth';
|
||||
|
||||
export type Role = 'admin' | 'editor';
|
||||
|
||||
export function parseRole(value: FormDataEntryValue | null): Role | null {
|
||||
return value === 'admin' || value === 'editor' ? value : null;
|
||||
}
|
||||
|
||||
export function listUsers(): PublicUser[] {
|
||||
return db
|
||||
.select({ id: users.id, email: users.email, name: users.name, role: users.role })
|
||||
.from(users)
|
||||
.orderBy(users.createdAt)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getUser(id: string) {
|
||||
return db.select().from(users).where(eq(users.id, id)).get();
|
||||
}
|
||||
|
||||
export function getUserByEmail(email: string) {
|
||||
return db.select().from(users).where(eq(users.email, email.toLowerCase())).get();
|
||||
}
|
||||
|
||||
export function countAdmins(): number {
|
||||
return db.select({ c: count() }).from(users).where(eq(users.role, 'admin')).get()?.c ?? 0;
|
||||
}
|
||||
|
||||
export async function createUser(input: {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
}): Promise<void> {
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
db.insert(users)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
email: input.email.toLowerCase(),
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
passwordHash
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export function updateUserRole(id: string, role: Role): void {
|
||||
db.update(users).set({ role }).where(eq(users.id, id)).run();
|
||||
}
|
||||
|
||||
export async function setUserPassword(id: string, password: string): Promise<void> {
|
||||
const passwordHash = await hashPassword(password);
|
||||
db.update(users).set({ passwordHash }).where(eq(users.id, id)).run();
|
||||
}
|
||||
|
||||
export function deleteUser(id: string): void {
|
||||
db.delete(users).where(eq(users.id, id)).run();
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import intelidom_icon from '$lib/assets/intelidom_icon.svg';
|
||||
|
||||
import '../app.css';
|
||||
|
|
@ -7,6 +8,10 @@
|
|||
import Footer from '$lib/Footer.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// The admin dashboard provides its own chrome, so hide the public site
|
||||
// header/footer on every /admin route (including the login page).
|
||||
const isAdmin = $derived(page.url.pathname.startsWith('/admin'));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -14,10 +19,14 @@
|
|||
<link rel="icon" href={intelidom_icon} />
|
||||
</svelte:head>
|
||||
|
||||
{#if !isAdmin}
|
||||
<Header></Header>
|
||||
{/if}
|
||||
|
||||
<div class="app-content">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
{#if !isAdmin}
|
||||
<Footer></Footer>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals }) => {
|
||||
// hooks.server.ts already blocks unauthenticated access; this also narrows the type.
|
||||
if (!locals.user) {
|
||||
throw redirect(303, '/admin/login');
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: locals.user.id,
|
||||
email: locals.user.email,
|
||||
name: locals.user.name,
|
||||
role: locals.user.role
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
||||
|
||||
const tabs = [
|
||||
{ href: '/admin/gallery', label: 'Galerija' },
|
||||
{ href: '/admin/services', label: 'Storitve' },
|
||||
{ href: '/admin/news', label: 'Novice' },
|
||||
{ href: '/admin/contact', label: 'Kontakt' }
|
||||
];
|
||||
|
||||
const isAdmin = $derived(data.user.role === 'admin');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Admin — InteliDom d.o.o.</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="dash">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<a href="/">InteliDom</a>
|
||||
<span>Admin</span>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
{#each tabs as tab (tab.href)}
|
||||
<a href={tab.href} class="tab" class:active={page.url.pathname.startsWith(tab.href)}>
|
||||
{tab.label}
|
||||
</a>
|
||||
{/each}
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin/users"
|
||||
class="tab"
|
||||
class:active={page.url.pathname.startsWith('/admin/users')}
|
||||
>
|
||||
Uporabniki
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<div class="account">
|
||||
<div class="who">
|
||||
<span class="who-name">{data.user.name}</span>
|
||||
<span class="who-role">{data.user.role}</span>
|
||||
</div>
|
||||
<form method="POST" action="/admin/logout">
|
||||
<button type="submit" class="logout">Odjava</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="dash-main">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dash {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #11161a;
|
||||
border-bottom: 1px solid rgba(229, 228, 234, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
color: #42af38;
|
||||
font-weight: 700;
|
||||
font-size: 1.2em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
color: #9aa0a6;
|
||||
font-size: 0.8em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.3em;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
color: #c7c9cf;
|
||||
text-decoration: none;
|
||||
padding: 0.5em 0.9em;
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: rgba(66, 175, 56, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.who-name {
|
||||
color: #e5e4ea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.who-role {
|
||||
color: #9aa0a6;
|
||||
font-size: 0.8em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.logout {
|
||||
background: transparent;
|
||||
color: #e5e4ea;
|
||||
border: 1px solid rgba(229, 228, 234, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 0.5em 1em;
|
||||
font-family: inherit;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.dash-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; /* allow the flex item to shrink so wide content wraps instead of overflowing */
|
||||
box-sizing: border-box;
|
||||
padding: 1.5em 1.5em 3em;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.dash {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: none;
|
||||
border-right: 1px solid rgba(229, 228, 234, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
nav {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.account {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// The dashboard landing page: send /admin to the first tab.
|
||||
export const load: PageServerLoad = () => {
|
||||
throw redirect(307, '/admin/gallery');
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
<!-- /admin redirects to /admin/gallery in +page.server.ts; this never renders. -->
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<section class="admin-section">
|
||||
<h1>Kontakt</h1>
|
||||
<div class="admin-card">
|
||||
<p class="admin-notice">Urejanje kontaktnih podatkov bo na voljo kmalu (Faza 5).</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { asc, desc, eq } from 'drizzle-orm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { galleryImages } from '$lib/server/db/schema';
|
||||
import { saveImage, deleteUpload } from '$lib/server/storage';
|
||||
import { SESSION_COOKIE, invalidateSession } from '$lib/server/auth';
|
||||
|
||||
function listImages() {
|
||||
return db
|
||||
|
|
@ -57,12 +56,5 @@ export const actions: Actions = {
|
|||
}
|
||||
|
||||
return { success: 'Slika izbrisana.' };
|
||||
},
|
||||
|
||||
logout: async ({ cookies }) => {
|
||||
const token = cookies.get(SESSION_COOKIE);
|
||||
if (token) invalidateSession(token);
|
||||
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
throw redirect(303, '/admin/login');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let fileInput: HTMLInputElement | undefined = $state();
|
||||
</script>
|
||||
|
||||
<section class="admin-section">
|
||||
<h1>Galerija</h1>
|
||||
|
||||
<form
|
||||
class="admin-card"
|
||||
method="POST"
|
||||
action="?/upload"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
uploading = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
uploading = false;
|
||||
if (fileInput) fileInput.value = '';
|
||||
};
|
||||
}}
|
||||
>
|
||||
<h2>Dodaj sliko</h2>
|
||||
<input bind:this={fileInput} type="file" name="images" accept="image/*" multiple required />
|
||||
<label>
|
||||
Opis (neobvezno)
|
||||
<input class="admin-input" type="text" name="caption" placeholder="npr. Montaža omarice" />
|
||||
</label>
|
||||
{#if form?.error}<p class="admin-error">{form.error}</p>{/if}
|
||||
{#if form?.success}<p class="admin-msg">{form.success}</p>{/if}
|
||||
<button class="admin-btn" type="submit" disabled={uploading}>
|
||||
{uploading ? 'Nalaganje…' : 'Naloži'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<h2>Slike v galeriji</h2>
|
||||
{#if data.images.length === 0}
|
||||
<p class="admin-notice">Ni slik. Naložite prvo zgoraj.</p>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each data.images as image (image.id)}
|
||||
<div class="tile">
|
||||
<img src={image.url} alt={image.caption ?? ''} loading="lazy" />
|
||||
{#if image.caption}<span class="cap">{image.caption}</span>{/if}
|
||||
<form method="POST" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="id" value={image.id} />
|
||||
<button class="del" type="submit" aria-label="Izbriši">Izbriši</button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #11161a;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tile .cap {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 0.5em 0.6em;
|
||||
font-size: 0.8em;
|
||||
color: #fff;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
.tile .del {
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 0.5em;
|
||||
background: rgba(220, 40, 40, 0.9);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.3em 0.6em;
|
||||
font-size: 0.8em;
|
||||
font-family: inherit;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<section class="admin-section">
|
||||
<h1>Novice</h1>
|
||||
<div class="admin-card">
|
||||
<p class="admin-notice">Urejanje novic bo na voljo kmalu (Faza 4).</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<section class="admin-section">
|
||||
<h1>Storitve</h1>
|
||||
<div class="admin-card">
|
||||
<p class="admin-notice">
|
||||
Urejanje storitev bo na voljo kmalu (Faza 3). Trenutno so storitve zapisane v kodi.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import {
|
||||
countAdmins,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUser,
|
||||
getUserByEmail,
|
||||
listUsers,
|
||||
parseRole,
|
||||
setUserPassword,
|
||||
updateUserRole
|
||||
} from '$lib/server/users';
|
||||
|
||||
function requireAdmin(locals: App.Locals) {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Samo administratorji lahko upravljajo uporabnike.');
|
||||
}
|
||||
return locals.user;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
// Editors don't manage users — send them back to the gallery.
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw redirect(303, '/admin/gallery');
|
||||
}
|
||||
return { users: listUsers(), currentUserId: locals.user.id };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, locals }) => {
|
||||
requireAdmin(locals);
|
||||
const form = await request.formData();
|
||||
const email = String(form.get('email') ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const password = String(form.get('password') ?? '');
|
||||
const role = parseRole(form.get('role'));
|
||||
|
||||
if (!email || !name || !password || !role) {
|
||||
return fail(400, { error: 'Izpolnite vsa polja.' });
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return fail(400, { error: 'Geslo mora imeti vsaj 8 znakov.' });
|
||||
}
|
||||
if (getUserByEmail(email)) {
|
||||
return fail(400, { error: 'Uporabnik s tem e-naslovom že obstaja.' });
|
||||
}
|
||||
|
||||
await createUser({ email, name, password, role });
|
||||
return { success: `Uporabnik ${email} dodan.` };
|
||||
},
|
||||
|
||||
setRole: async ({ request, locals }) => {
|
||||
requireAdmin(locals);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const role = parseRole(form.get('role'));
|
||||
if (!role) return fail(400, { error: 'Neveljavna vloga.' });
|
||||
|
||||
const target = getUser(id);
|
||||
if (!target) return fail(404, { error: 'Uporabnik ne obstaja.' });
|
||||
|
||||
if (target.role === 'admin' && role === 'editor' && countAdmins() <= 1) {
|
||||
return fail(400, { error: 'Vsaj en administrator mora ostati.' });
|
||||
}
|
||||
|
||||
updateUserRole(id, role);
|
||||
return { success: 'Vloga posodobljena.' };
|
||||
},
|
||||
|
||||
resetPassword: async ({ request, locals }) => {
|
||||
requireAdmin(locals);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const password = String(form.get('password') ?? '');
|
||||
if (password.length < 8) {
|
||||
return fail(400, { error: 'Geslo mora imeti vsaj 8 znakov.' });
|
||||
}
|
||||
if (!getUser(id)) return fail(404, { error: 'Uporabnik ne obstaja.' });
|
||||
|
||||
await setUserPassword(id, password);
|
||||
return { success: 'Geslo posodobljeno.' };
|
||||
},
|
||||
|
||||
delete: async ({ request, locals }) => {
|
||||
const admin = requireAdmin(locals);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
|
||||
if (id === admin.id) {
|
||||
return fail(400, { error: 'Ne morete izbrisati lastnega računa.' });
|
||||
}
|
||||
const target = getUser(id);
|
||||
if (!target) return fail(404, { error: 'Uporabnik ne obstaja.' });
|
||||
if (target.role === 'admin' && countAdmins() <= 1) {
|
||||
return fail(400, { error: 'Vsaj en administrator mora ostati.' });
|
||||
}
|
||||
|
||||
deleteUser(id);
|
||||
return { success: 'Uporabnik izbrisan.' };
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// Which user's "reset password" box is open.
|
||||
let resettingId: string | null = $state(null);
|
||||
</script>
|
||||
|
||||
<section class="admin-section">
|
||||
<h1>Uporabniki</h1>
|
||||
|
||||
<form class="admin-card" method="POST" action="?/create" use:enhance>
|
||||
<h2>Dodaj uporabnika</h2>
|
||||
<div class="fields">
|
||||
<label>
|
||||
Ime
|
||||
<input class="admin-input" type="text" name="name" required />
|
||||
</label>
|
||||
<label>
|
||||
E-naslov
|
||||
<input class="admin-input" type="email" name="email" required />
|
||||
</label>
|
||||
<label>
|
||||
Geslo
|
||||
<input class="admin-input" type="password" name="password" minlength="8" required />
|
||||
</label>
|
||||
<label>
|
||||
Vloga
|
||||
<select class="admin-input" name="role">
|
||||
<option value="editor">Urejevalec</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{#if form?.error}<p class="admin-error">{form.error}</p>{/if}
|
||||
{#if form?.success}<p class="admin-msg">{form.success}</p>{/if}
|
||||
<button class="admin-btn" type="submit">Dodaj</button>
|
||||
</form>
|
||||
|
||||
<h2>Obstoječi uporabniki</h2>
|
||||
<div class="users">
|
||||
{#each data.users as u (u.id)}
|
||||
<div class="user-row">
|
||||
<div class="info">
|
||||
<span class="name">{u.name}{#if u.id === data.currentUserId}<span class="you"> (vi)</span>{/if}</span>
|
||||
<span class="email">{u.email}</span>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<form method="POST" action="?/setRole" use:enhance class="role-form">
|
||||
<input type="hidden" name="id" value={u.id} />
|
||||
<select class="admin-input role-select" name="role" value={u.role}>
|
||||
<option value="editor">Urejevalec</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
<button class="admin-btn" type="submit">Shrani</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
class="admin-btn admin-btn-ghost"
|
||||
type="button"
|
||||
onclick={() => (resettingId = resettingId === u.id ? null : u.id)}
|
||||
>
|
||||
Geslo
|
||||
</button>
|
||||
|
||||
<form method="POST" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="id" value={u.id} />
|
||||
<button
|
||||
class="admin-btn admin-btn-danger"
|
||||
type="submit"
|
||||
disabled={u.id === data.currentUserId}
|
||||
>
|
||||
Izbriši
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#if resettingId === u.id}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/resetPassword"
|
||||
use:enhance={() => async ({ update }) => {
|
||||
await update();
|
||||
resettingId = null;
|
||||
}}
|
||||
class="reset-form"
|
||||
>
|
||||
<input type="hidden" name="id" value={u.id} />
|
||||
<input
|
||||
class="admin-input"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Novo geslo (min. 8 znakov)"
|
||||
minlength="8"
|
||||
required
|
||||
/>
|
||||
<button class="admin-btn" type="submit">Nastavi geslo</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.9em;
|
||||
}
|
||||
|
||||
.users {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6em;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
background: #11161a;
|
||||
border: 1px solid rgba(229, 228, 234, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 0.9em 1em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8em;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.you {
|
||||
color: #42af38;
|
||||
font-weight: 400;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.email {
|
||||
color: #9aa0a6;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.role-form {
|
||||
display: flex;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
.role-select {
|
||||
width: auto;
|
||||
padding: 0.4em 0.5em;
|
||||
}
|
||||
|
||||
.reset-form {
|
||||
flex-basis: 100%;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
.reset-form .admin-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals, url }) => {
|
||||
const onLogin = url.pathname === '/admin/login';
|
||||
|
||||
if (!locals.user && !onLogin) {
|
||||
throw redirect(303, '/admin/login');
|
||||
}
|
||||
if (locals.user && onLogin) {
|
||||
throw redirect(303, '/admin');
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.user
|
||||
? {
|
||||
id: locals.user.id,
|
||||
email: locals.user.email,
|
||||
name: locals.user.name,
|
||||
role: locals.user.role
|
||||
}
|
||||
: null
|
||||
};
|
||||
};
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let fileInput: HTMLInputElement | undefined = $state();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Admin — InteliDom d.o.o.</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="admin">
|
||||
<div class="bar">
|
||||
<h1>Admin</h1>
|
||||
<div class="bar-right">
|
||||
{#if data.user}<span>Prijavljeni kot <strong>{data.user.name}</strong></span>{/if}
|
||||
<form method="POST" action="?/logout" use:enhance>
|
||||
<button class="ghost" type="submit">Odjava</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="card upload"
|
||||
method="POST"
|
||||
action="?/upload"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
uploading = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
uploading = false;
|
||||
if (fileInput) fileInput.value = '';
|
||||
};
|
||||
}}
|
||||
>
|
||||
<h2>Dodaj sliko v galerijo</h2>
|
||||
<input bind:this={fileInput} type="file" name="images" accept="image/*" multiple required />
|
||||
<label>
|
||||
Opis (neobvezno)
|
||||
<input type="text" name="caption" placeholder="npr. Montaža omarice" />
|
||||
</label>
|
||||
{#if form?.error}<p class="error">{form.error}</p>{/if}
|
||||
{#if form?.success}<p class="msg">{form.success}</p>{/if}
|
||||
<button type="submit" disabled={uploading}>{uploading ? 'Nalaganje…' : 'Naloži'}</button>
|
||||
</form>
|
||||
|
||||
<h2 class="section-title">Slike v galeriji</h2>
|
||||
{#if data.images.length === 0}
|
||||
<p class="notice">Ni slik. Naložite prvo zgoraj.</p>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each data.images as image (image.id)}
|
||||
<div class="tile">
|
||||
<img src={image.url} alt={image.caption ?? ''} loading="lazy" />
|
||||
{#if image.caption}<span class="cap">{image.caption}</span>{/if}
|
||||
<form method="POST" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="id" value={image.id} />
|
||||
<button class="del" type="submit" aria-label="Izbriši">Izbriši</button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.admin {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1em 3em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.3em;
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
flex-wrap: wrap;
|
||||
color: #c7c9cf;
|
||||
}
|
||||
|
||||
.bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #11161a;
|
||||
border: 1px solid rgba(229, 228, 234, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 1.4em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9em;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35em;
|
||||
font-size: 0.9em;
|
||||
color: #c7c9cf;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
background: #0a0d0f;
|
||||
border: 1px solid rgba(229, 228, 234, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.6em 0.7em;
|
||||
color: #e5e4ea;
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
input[type='file'] {
|
||||
color: #c7c9cf;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #42af38;
|
||||
color: #06210a;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.6em 1.1em;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: #e5e4ea;
|
||||
border: 1px solid rgba(229, 228, 234, 0.3);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.msg {
|
||||
color: #42af38;
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #11161a;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tile .cap {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 0.5em 0.6em;
|
||||
font-size: 0.8em;
|
||||
color: #fff;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
.tile .del {
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
right: 0.5em;
|
||||
background: rgba(220, 40, 40, 0.9);
|
||||
color: #fff;
|
||||
padding: 0.3em 0.6em;
|
||||
font-size: 0.8em;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { dev } from '$app/environment';
|
||||
import type { Actions } from './$types';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { SESSION_COOKIE, createSession, findUserByEmail, verifyPassword } from '$lib/server/auth';
|
||||
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
if (locals.user) {
|
||||
throw redirect(303, '/admin');
|
||||
}
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
const form = await request.formData();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { SESSION_COOKIE, invalidateSession } from '$lib/server/auth';
|
||||
|
||||
export const POST: RequestHandler = ({ cookies }) => {
|
||||
const token = cookies.get(SESSION_COOKIE);
|
||||
if (token) invalidateSession(token);
|
||||
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
throw redirect(303, '/admin/login');
|
||||
};
|
||||
Loading…
Reference in New Issue