diff --git a/src/app.css b/src/app.css index 62d9972..180d75f 100644 --- a/src/app.css +++ b/src/app.css @@ -20,4 +20,86 @@ body { display: flex; 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; } \ No newline at end of file diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 01e7c2a..090da2f 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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); }; diff --git a/src/lib/server/users.ts b/src/lib/server/users.ts new file mode 100644 index 0000000..393ca93 --- /dev/null +++ b/src/lib/server/users.ts @@ -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 { + 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 { + 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(); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6c0f3a9..6c6bf25 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,4 +1,5 @@ @@ -14,10 +19,14 @@ -
+{#if !isAdmin} +
+{/if}
{@render children?.()}
-
+{#if !isAdmin} +
+{/if} diff --git a/src/routes/admin/(dashboard)/+layout.server.ts b/src/routes/admin/(dashboard)/+layout.server.ts new file mode 100644 index 0000000..e925fd2 --- /dev/null +++ b/src/routes/admin/(dashboard)/+layout.server.ts @@ -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 + } + }; +}; diff --git a/src/routes/admin/(dashboard)/+layout.svelte b/src/routes/admin/(dashboard)/+layout.svelte new file mode 100644 index 0000000..5764824 --- /dev/null +++ b/src/routes/admin/(dashboard)/+layout.svelte @@ -0,0 +1,200 @@ + + + + Admin — InteliDom d.o.o. + + + +
+ + +
+ {@render children()} +
+
+ + diff --git a/src/routes/admin/(dashboard)/+page.server.ts b/src/routes/admin/(dashboard)/+page.server.ts new file mode 100644 index 0000000..367d6b8 --- /dev/null +++ b/src/routes/admin/(dashboard)/+page.server.ts @@ -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'); +}; diff --git a/src/routes/admin/(dashboard)/+page.svelte b/src/routes/admin/(dashboard)/+page.svelte new file mode 100644 index 0000000..6f5e502 --- /dev/null +++ b/src/routes/admin/(dashboard)/+page.svelte @@ -0,0 +1 @@ + diff --git a/src/routes/admin/(dashboard)/contact/+page.svelte b/src/routes/admin/(dashboard)/contact/+page.svelte new file mode 100644 index 0000000..63609a9 --- /dev/null +++ b/src/routes/admin/(dashboard)/contact/+page.svelte @@ -0,0 +1,6 @@ +
+

Kontakt

+
+

Urejanje kontaktnih podatkov bo na voljo kmalu (Faza 5).

+
+
diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/(dashboard)/gallery/+page.server.ts similarity index 83% rename from src/routes/admin/+page.server.ts rename to src/routes/admin/(dashboard)/gallery/+page.server.ts index 7f4ab99..0d60833 100644 --- a/src/routes/admin/+page.server.ts +++ b/src/routes/admin/(dashboard)/gallery/+page.server.ts @@ -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'); } }; diff --git a/src/routes/admin/(dashboard)/gallery/+page.svelte b/src/routes/admin/(dashboard)/gallery/+page.svelte new file mode 100644 index 0000000..82a5c5a --- /dev/null +++ b/src/routes/admin/(dashboard)/gallery/+page.svelte @@ -0,0 +1,106 @@ + + +
+

Galerija

+ +
{ + uploading = true; + return async ({ update }) => { + await update(); + uploading = false; + if (fileInput) fileInput.value = ''; + }; + }} + > +

Dodaj sliko

+ + + {#if form?.error}

{form.error}

{/if} + {#if form?.success}

{form.success}

{/if} + +
+ +

Slike v galeriji

+ {#if data.images.length === 0} +

Ni slik. Naložite prvo zgoraj.

+ {:else} +
+ {#each data.images as image (image.id)} +
+ {image.caption + {#if image.caption}{image.caption}{/if} +
+ + +
+
+ {/each} +
+ {/if} +
+ + diff --git a/src/routes/admin/(dashboard)/news/+page.svelte b/src/routes/admin/(dashboard)/news/+page.svelte new file mode 100644 index 0000000..7557037 --- /dev/null +++ b/src/routes/admin/(dashboard)/news/+page.svelte @@ -0,0 +1,6 @@ +
+

Novice

+
+

Urejanje novic bo na voljo kmalu (Faza 4).

+
+
diff --git a/src/routes/admin/(dashboard)/services/+page.svelte b/src/routes/admin/(dashboard)/services/+page.svelte new file mode 100644 index 0000000..61b3b46 --- /dev/null +++ b/src/routes/admin/(dashboard)/services/+page.svelte @@ -0,0 +1,8 @@ +
+

Storitve

+
+

+ Urejanje storitev bo na voljo kmalu (Faza 3). Trenutno so storitve zapisane v kodi. +

+
+
diff --git a/src/routes/admin/(dashboard)/users/+page.server.ts b/src/routes/admin/(dashboard)/users/+page.server.ts new file mode 100644 index 0000000..2a0691e --- /dev/null +++ b/src/routes/admin/(dashboard)/users/+page.server.ts @@ -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.' }; + } +}; diff --git a/src/routes/admin/(dashboard)/users/+page.svelte b/src/routes/admin/(dashboard)/users/+page.svelte new file mode 100644 index 0000000..94f2723 --- /dev/null +++ b/src/routes/admin/(dashboard)/users/+page.svelte @@ -0,0 +1,187 @@ + + +
+

Uporabniki

+ +
+

Dodaj uporabnika

+
+ + + + +
+ {#if form?.error}

{form.error}

{/if} + {#if form?.success}

{form.success}

{/if} + +
+ +

Obstoječi uporabniki

+
+ {#each data.users as u (u.id)} +
+
+ {u.name}{#if u.id === data.currentUserId} (vi){/if} + +
+ +
+
+ + + +
+ + + +
+ + +
+
+ + {#if resettingId === u.id} +
async ({ update }) => { + await update(); + resettingId = null; + }} + class="reset-form" + > + + + +
+ {/if} +
+ {/each} +
+
+ + diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts deleted file mode 100644 index 5c91513..0000000 --- a/src/routes/admin/+layout.server.ts +++ /dev/null @@ -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 - }; -}; diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte deleted file mode 100644 index 9cd1aa9..0000000 --- a/src/routes/admin/+page.svelte +++ /dev/null @@ -1,227 +0,0 @@ - - - - Admin — InteliDom d.o.o. - - - -
-
-

Admin

-
- {#if data.user}Prijavljeni kot {data.user.name}{/if} -
- -
-
-
- -
{ - uploading = true; - return async ({ update }) => { - await update(); - uploading = false; - if (fileInput) fileInput.value = ''; - }; - }} - > -

Dodaj sliko v galerijo

- - - {#if form?.error}

{form.error}

{/if} - {#if form?.success}

{form.success}

{/if} - -
- -

Slike v galeriji

- {#if data.images.length === 0} -

Ni slik. Naložite prvo zgoraj.

- {:else} -
- {#each data.images as image (image.id)} -
- {image.caption - {#if image.caption}{image.caption}{/if} -
- - -
-
- {/each} -
- {/if} -
- - diff --git a/src/routes/admin/login/+page.server.ts b/src/routes/admin/login/+page.server.ts index 0a1442d..41147be 100644 --- a/src/routes/admin/login/+page.server.ts +++ b/src/routes/admin/login/+page.server.ts @@ -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(); diff --git a/src/routes/admin/logout/+server.ts b/src/routes/admin/logout/+server.ts new file mode 100644 index 0000000..7c1c6fc --- /dev/null +++ b/src/routes/admin/logout/+server.ts @@ -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'); +};