diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 1c5f2e4..ec678eb 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -76,6 +76,11 @@ func main() { } log.Println("Migracije uspešno izvršene") + // popuni tabelu dozvola podrazumevanim vrednostima ako je prazna + if err := sqlite.InicijalizujDozvole(context.Background(), db, ntechmw.ImaDozvolu, ntechmw.SveAkcije()); err != nil { + log.Printf("Upozorenje: greška pri inicijalizaciji dozvola: %v", err) + } + napraviStartupBackup(putanjaBaze) // periodično brisanje isteklih sesija i starih pokušaja prijave @@ -190,15 +195,25 @@ func main() { r.Post("/podsetnici/zavrseno/{id}", h.OznaciPodsetnik) r.Post("/podsetnici/obrisi/{id}", h.ObrisiPodsetnik) - // rute dostupne samo superadminu + // rute dostupne adminu i superadminu (superadmin vidi sve, admin ne vidi superadmin naloge) r.Group(func(r chi.Router) { - r.Use(ntechmw.RequireSuperAdmin) + r.Use(ntechmw.RequireAdmin) r.Get("/admin/korisnici", h.AdminKorisnici) r.Get("/admin/korisnici/{id}/istorija", h.AdminLoginIstorija) r.Post("/admin/korisnici/novi", h.AdminSacuvajKorisnika) r.Post("/admin/korisnici/{id}/aktivan", h.AdminToggleAktivan) r.Post("/admin/korisnici/{id}/uloga", h.AdminPromeniUlogu) r.Post("/admin/korisnici/{id}/obrisi", h.AdminObrisiKorisnika) + // dozvole — pregled i izmena matrice dostupni adminu, promena uloge samo superadminu + r.Get("/admin/dozvole", h.AdminDozvole) + r.Post("/admin/dozvole/sacuvaj", h.AdminDozvoleSacuvaj) + r.Post("/admin/dozvole/reset", h.AdminDozvoleReset) + }) + + // rute dostupne samo superadminu + r.Group(func(r chi.Router) { + r.Use(ntechmw.RequireSuperAdmin) + r.Post("/admin/dozvole/uloga/{id}", h.AdminDozvolePromeniUlogu) }) r.Get("/admin/profil", h.AdminProfil) r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku) diff --git a/internal/db/repository.go b/internal/db/repository.go index 3ea4e3c..39ac308 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -118,6 +118,14 @@ type LoginIstorijsaRepository interface { ListaZaKorisnika(ctx context.Context, korisnikID int64, limit int) ([]*model.LoginPokusaj, error) } +// DozvoleRepository definiše operacije nad dozvolama po ulogama +type DozvoleRepository interface { + ImaDozvolu(ctx context.Context, uloga, akcija string) bool + SveDozvole(ctx context.Context, uloga string) map[string]bool + Sacuvaj(ctx context.Context, uloga, akcija string, dozvoljeno bool) error + Reset(ctx context.Context) error +} + // PodsetnikRepository definiše operacije nad podsetnicima type PodsetnikRepository interface { Lista(ctx context.Context, filter PodsetnikFilter) ([]model.Podsetnik, error) diff --git a/internal/db/sqlite/dozvole.go b/internal/db/sqlite/dozvole.go new file mode 100644 index 0000000..4e3d754 --- /dev/null +++ b/internal/db/sqlite/dozvole.go @@ -0,0 +1,109 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" +) + +type sqliteDozvoleRepo struct { + db *sql.DB + defaultFn func(uloga, akcija string) bool // podrazumevane vrednosti iz middleware/dozvole.go + sveAkcije []string +} + +// NoviDozvoleRepo kreira SQLite implementaciju DozvoleRepository +func NoviDozvoleRepo(db *sql.DB, defaultFn func(uloga, akcija string) bool, sveAkcije []string) *sqliteDozvoleRepo { + return &sqliteDozvoleRepo{db: db, defaultFn: defaultFn, sveAkcije: sveAkcije} +} + +// InicijalizujDozvole popunjava tabelu podrazumevanim vrednostima ako je prazna +func InicijalizujDozvole(ctx context.Context, db *sql.DB, defaultFn func(uloga, akcija string) bool, sveAkcije []string) error { + var br int + _ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM dozvole`).Scan(&br) + if br > 0 { + return nil + } + return popuniPodrazumevano(ctx, db, defaultFn, sveAkcije) +} + +// popuniPodrazumevano upisuje sve podrazumevane dozvole u bazu +func popuniPodrazumevano(ctx context.Context, db *sql.DB, defaultFn func(uloga, akcija string) bool, sveAkcije []string) error { + for _, uloga := range []string{"radnik", "admin", "superadmin"} { + for _, akcija := range sveAkcije { + dozvoljeno := 0 + if defaultFn(uloga, akcija) { + dozvoljeno = 1 + } + if _, err := db.ExecContext(ctx, + `INSERT OR IGNORE INTO dozvole (uloga, akcija, dozvoljeno) VALUES (?, ?, ?)`, + uloga, akcija, dozvoljeno); err != nil { + return fmt.Errorf("ntech: dozvole.Inicijalizuj: %w", err) + } + } + } + return nil +} + +func (r *sqliteDozvoleRepo) ImaDozvolu(ctx context.Context, uloga, akcija string) bool { + var dozvoljeno int + err := r.db.QueryRowContext(ctx, + `SELECT dozvoljeno FROM dozvole WHERE uloga = ? AND akcija = ?`, uloga, akcija).Scan(&dozvoljeno) + if err != nil { + // fallback na podrazumevano ako red nije pronađen + return r.defaultFn(uloga, akcija) + } + return dozvoljeno == 1 +} + +func (r *sqliteDozvoleRepo) SveDozvole(ctx context.Context, uloga string) map[string]bool { + rows, err := r.db.QueryContext(ctx, + `SELECT akcija, dozvoljeno FROM dozvole WHERE uloga = ?`, uloga) + if err != nil { + // fallback na podrazumevano + m := make(map[string]bool, len(r.sveAkcije)) + for _, a := range r.sveAkcije { + m[a] = r.defaultFn(uloga, a) + } + return m + } + defer rows.Close() + + m := make(map[string]bool, len(r.sveAkcije)) + for rows.Next() { + var akcija string + var dozvoljeno int + if err := rows.Scan(&akcija, &dozvoljeno); err == nil { + m[akcija] = dozvoljeno == 1 + } + } + // popuni eventualno nedostajuće akcije podrazumevanim vrednostima + for _, a := range r.sveAkcije { + if _, ok := m[a]; !ok { + m[a] = r.defaultFn(uloga, a) + } + } + return m +} + +func (r *sqliteDozvoleRepo) Sacuvaj(ctx context.Context, uloga, akcija string, dozvoljeno bool) error { + d := 0 + if dozvoljeno { + d = 1 + } + _, err := r.db.ExecContext(ctx, + `INSERT INTO dozvole (uloga, akcija, dozvoljeno) VALUES (?, ?, ?) + ON CONFLICT (uloga, akcija) DO UPDATE SET dozvoljeno = excluded.dozvoljeno`, + uloga, akcija, d) + if err != nil { + return fmt.Errorf("ntech: dozvole.Sacuvaj: %w", err) + } + return nil +} + +func (r *sqliteDozvoleRepo) Reset(ctx context.Context) error { + if _, err := r.db.ExecContext(ctx, `DELETE FROM dozvole`); err != nil { + return fmt.Errorf("ntech: dozvole.Reset: %w", err) + } + return popuniPodrazumevano(ctx, r.db, r.defaultFn, r.sveAkcije) +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go index c87e161..1416e4c 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -3,7 +3,6 @@ package handler import ( "html/template" "net/http" - "strconv" "ntech/internal/auth" "ntech/internal/db/sqlite" @@ -51,6 +50,17 @@ func (h *Handler) AdminKorisnici(w http.ResponseWriter, r *http.Request) { return } + // admin ne sme da vidi superadmin naloge — filtriramo ih iz liste + if k.Uloga != "superadmin" { + filtrirano := lista[:0] + for _, kor := range lista { + if kor.Uloga != "superadmin" { + filtrirano = append(filtrirano, kor) + } + } + lista = filtrirano + } + ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "admin" ps.NaslovStranice = "Korisnici" @@ -129,6 +139,12 @@ func (h *Handler) AdminToggleAktivan(w http.ResponseWriter, r *http.Request) { return } + // admin ne sme da menja status drugog admina ni superadmina + if korisnik.Uloga == "superadmin" || (korisnik.Uloga == "admin" && k.Uloga != "superadmin") { + http.Redirect(w, r, "/admin/korisnici?greska=3", http.StatusSeeOther) + return + } + if err := h.KorisniciRepo.AzurirajAktivan(r.Context(), id, !korisnik.Aktivan); err != nil { http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther) return @@ -413,6 +429,12 @@ func (h *Handler) AdminLoginIstorija(w http.ResponseWriter, r *http.Request) { return } + // admin ne sme da vidi istoriju superadmin naloga + if korisnik.Uloga == "superadmin" && k.Uloga != "superadmin" { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + istorija, err := h.LoginIstorijsaRepo.ListaZaKorisnika(r.Context(), id, 50) if err != nil { http.Error(w, "Greška pri učitavanju istorije", http.StatusInternalServerError) @@ -431,8 +453,137 @@ func (h *Handler) AdminLoginIstorija(w http.ResponseWriter, r *http.Request) { }) } -// parseBoolForm čita boolean vrednost iz forme -func parseBoolForm(s string) bool { - b, _ := strconv.ParseBool(s) - return b +type podaciAdminDozvole struct { + model.PodaciStranice + Korisnici []model.Korisnik + TrenutniID int64 + DozvoleRadnik map[string]bool + DozvoleAdmin map[string]bool + DozvoleSuperadmin map[string]bool + Greska string + Sacuvano bool } + +// AdminDozvole prikazuje stranicu za upravljanje ulogama i pregled dozvola +func (h *Handler) AdminDozvole(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !middleware.JeAdmin(k) { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + + podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + lista, err := h.KorisniciRepo.Lista(r.Context()) + if err != nil { + http.Error(w, "Greška pri učitavanju korisnika", http.StatusInternalServerError) + return + } + + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "dozvole" + ps.NaslovStranice = "Dozvole" + + h.renderujTemplate(w, "admin_dozvole", podaciAdminDozvole{ + PodaciStranice: ps, + Korisnici: lista, + TrenutniID: k.ID, + DozvoleRadnik: h.DozvoleRepo.SveDozvole(r.Context(), "radnik"), + DozvoleAdmin: h.DozvoleRepo.SveDozvole(r.Context(), "admin"), + DozvoleSuperadmin: h.DozvoleRepo.SveDozvole(r.Context(), "superadmin"), + Greska: r.URL.Query().Get("greska"), + Sacuvano: r.URL.Query().Get("sacuvano") == "1", + }) +} + +// AdminDozvolePromeniUlogu menja ulogu korisnika sa stranice dozvola +func (h *Handler) AdminDozvolePromeniUlogu(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil || k.Uloga != "superadmin" { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Redirect(w, r, "/admin/dozvole?greska=1", http.StatusSeeOther) + return + } + + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/admin/dozvole?greska=1", http.StatusSeeOther) + return + } + + // superadmin ne može menjati svoju vlastitu ulogu + if id == k.ID { + http.Redirect(w, r, "/admin/dozvole?greska=3", http.StatusSeeOther) + return + } + + uloga := r.FormValue("uloga") + // superadmin uloga se ne može dodeliti kroz interfejs + validneUloge := map[string]bool{"admin": true, "radnik": true} + if !validneUloge[uloga] { + http.Redirect(w, r, "/admin/dozvole?greska=1", http.StatusSeeOther) + return + } + + ciljni, err := h.KorisniciRepo.DohvatiPoID(r.Context(), id) + if err != nil { + http.Redirect(w, r, "/admin/dozvole?greska=2", http.StatusSeeOther) + return + } + + // superadmin uloga se ne može menjati + if ciljni.Uloga == "superadmin" { + http.Redirect(w, r, "/admin/dozvole?greska=3", http.StatusSeeOther) + return + } + + if err := h.KorisniciRepo.AzurirajUlogu(r.Context(), id, uloga); err != nil { + http.Redirect(w, r, "/admin/dozvole?greska=2", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther) +} + +// AdminDozvoleSacuvaj prima POST i čuva promene dozvola za radnik i admin uloge +func (h *Handler) AdminDozvoleSacuvaj(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !middleware.JeAdmin(k) { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/admin/dozvole?greska=1", http.StatusSeeOther) + return + } + // čuvamo dozvole samo za radnik i admin — superadmin uvek ima sve + for _, uloga := range []string{"radnik", "admin"} { + for _, akcija := range middleware.SveAkcije() { + kljuc := uloga + "__" + akcija + dozvoljeno := r.FormValue(kljuc) == "on" + if err := h.DozvoleRepo.Sacuvaj(r.Context(), uloga, akcija, dozvoljeno); err != nil { + http.Redirect(w, r, "/admin/dozvole?greska=2", http.StatusSeeOther) + return + } + } + } + http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther) +} + +// AdminDozvoleReset vraća sve dozvole na podrazumevane vrednosti — samo superadmin +func (h *Handler) AdminDozvoleReset(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil || k.Uloga != "superadmin" { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + if err := h.DozvoleRepo.Reset(r.Context()); err != nil { + http.Redirect(w, r, "/admin/dozvole?greska=2", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/admin/dozvole?sacuvano=1", http.StatusSeeOther) +} + diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index 8a55743..d0dd464 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -13,6 +13,18 @@ import ( func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + // pročitaj i odmah obrišt flash poruku ako postoji + var flashGreska string + if kol, err := r.Cookie("ntech_flash_greska"); err == nil { + flashGreska = kol.Value + http.SetCookie(w, &http.Cookie{ + Name: "ntech_flash_greska", + Value: "", + Path: "/", + MaxAge: -1, + }) + } + podesavanja, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) @@ -126,17 +138,12 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { } } + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "dashboard" + ps.NaslovStranice = "Dashboard" + podaci := model.PodaciDashboarda{ - PodaciStranice: model.PodaciStranice{ - Stranica: "dashboard", - NaslovStranice: "Dashboard", - Tema: podesavanja["tema"], - NazivFirme: podesavanja["naziv_firme"], - Podnazlov: podesavanja["podnazlov"], - LogoTip: podesavanja["logo_tip"], - LogoPutanja: podesavanja["logo_putanja"], - Korisnik: "Admin", - }, + PodaciStranice: ps, BrojArtikala: brojArtikala, AktivniServisi: aktivniServisi, PrihodOvogMeseca: prihodOvogMeseca, @@ -145,6 +152,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { PoslednjiServisi: poslednjiServisi, KriticneZalihe: kriticneZalihe, PoslednjeProdaje: poslednjeProdaje, + FlashGreska: flashGreska, } h.renderujTemplate(w, "dashboard", podaci) diff --git a/internal/handler/dobavljac.go b/internal/handler/dobavljac.go index fa25e2a..33df8da 100644 --- a/internal/handler/dobavljac.go +++ b/internal/handler/dobavljac.go @@ -5,6 +5,7 @@ import ( "strings" "ntech/internal/db/sqlite" + "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -76,6 +77,11 @@ func (h *Handler) NoviDobavljac(w http.ResponseWriter, r *http.Request) { // SacuvajDobavljaca prima POST formu i upisuje novog dobavljača u bazu func (h *Handler) SacuvajDobavljaca(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "dobavljac.dodaj") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } if err := r.ParseForm(); err != nil { http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) return @@ -136,6 +142,11 @@ func (h *Handler) IzmeniDobavljaca(w http.ResponseWriter, r *http.Request) { // SacuvajIzmeneDobavljaca prima POST formu i ažurira postojećeg dobavljača u bazi func (h *Handler) SacuvajIzmeneDobavljaca(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "dobavljac.izmeni") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID dobavljača", http.StatusBadRequest) @@ -174,6 +185,11 @@ func (h *Handler) SacuvajIzmeneDobavljaca(w http.ResponseWriter, r *http.Request // ObrisiDobavljaca prima POST zahtev i briše dobavljača po ID-u func (h *Handler) ObrisiDobavljaca(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "dobavljac.obrisi") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID dobavljača", http.StatusBadRequest) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index ea23c67..535155d 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -28,6 +28,7 @@ type Handler struct { PodsetniciFRepo db.PodsetnikRepository PokusajiRepo db.PokusajiPrijaveRepository LoginIstorijsaRepo db.LoginIstorijsaRepository + DozvoleRepo db.DozvoleRepository Verzija string Templates map[string]*template.Template TemplatesFS fs.FS @@ -49,6 +50,7 @@ func Novi(baza *sql.DB) *Handler { PodsetniciFRepo: sqlite.NoviPodsetnikRepo(baza), PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza), LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza), + DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()), } } @@ -67,6 +69,7 @@ func (h *Handler) reinicijalzijRepozitorijume(novaDB *sql.DB) { h.PodsetniciFRepo = sqlite.NoviPodsetnikRepo(novaDB) h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB) h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB) + h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije()) } // popuniPodaciStranice popunjava zajednička polja stranice uključujući prijavljenog korisnika @@ -83,6 +86,7 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s ps.Korisnik = k.KorisnickoIme ps.KorisnikIme = k.KorisnickoIme ps.KorisnikUloga = k.Uloga + ps.Dozvole = h.DozvoleRepo.SveDozvole(r.Context(), k.Uloga) } ps.CsrfToken = middleware.CsrfToken(r.Context()) return ps diff --git a/internal/handler/izvestaji.go b/internal/handler/izvestaji.go index 5a5cc99..eedbdc9 100644 --- a/internal/handler/izvestaji.go +++ b/internal/handler/izvestaji.go @@ -9,6 +9,7 @@ import ( "time" "ntech/internal/db/sqlite" + "ntech/internal/middleware" "ntech/internal/model" ) @@ -73,6 +74,11 @@ type TopKlijent struct { // Izvestaji renderuje stranicu sa izveštajima func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "izvestaj.pregled") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } ctx := r.Context() podesavanja, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB) @@ -239,17 +245,12 @@ func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) { } } + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "izvestaji" + ps.NaslovStranice = "Izveštaji" + podaci := PodaciIzvestaja{ - PodaciStranice: model.PodaciStranice{ - Stranica: "izvestaji", - NaslovStranice: "Izveštaji", - Tema: podesavanja["tema"], - NazivFirme: podesavanja["naziv_firme"], - Podnazlov: podesavanja["podnazlov"], - LogoTip: podesavanja["logo_tip"], - LogoPutanja: podesavanja["logo_putanja"], - Korisnik: "Admin", - }, + PodaciStranice: ps, MesecniPrihodi: mesecniPrihodi, GrafikonJSON: template.JS(jsonBytes), StariNalozi: stariNalozi, diff --git a/internal/handler/kategorija.go b/internal/handler/kategorija.go index d1ff218..af80602 100644 --- a/internal/handler/kategorija.go +++ b/internal/handler/kategorija.go @@ -5,6 +5,7 @@ import ( "strconv" "ntech/internal/db/sqlite" + "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -47,6 +48,11 @@ func (h *Handler) Kategorije(w http.ResponseWriter, r *http.Request) { // DodajKategoriju prima POST i čuva novu kategoriju func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) { + kor := middleware.KorisnikIzKonteksta(r.Context()) + if kor == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), kor.Uloga, "kategorija.dodaj") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } if err := r.ParseForm(); err != nil { http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) return @@ -73,6 +79,11 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) { // ObrisiKategoriju briše kategoriju po ID-u func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "kategorija.obrisi") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { diff --git a/internal/handler/klijent.go b/internal/handler/klijent.go index 68873bd..d841289 100644 --- a/internal/handler/klijent.go +++ b/internal/handler/klijent.go @@ -6,6 +6,7 @@ import ( "strings" "ntech/internal/db/sqlite" + "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -77,6 +78,11 @@ func (h *Handler) NoviKlijent(w http.ResponseWriter, r *http.Request) { // SacuvajKlijenta prima POST formu i upisuje novog klijenta u bazu func (h *Handler) SacuvajKlijenta(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "klijent.dodaj") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } if err := r.ParseForm(); err != nil { http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) return @@ -147,6 +153,11 @@ func (h *Handler) IzmeniKlijenta(w http.ResponseWriter, r *http.Request) { // SacuvajIzmenuKlijenta prima POST formu i ažurira postojećeg klijenta u bazi func (h *Handler) SacuvajIzmenuKlijenta(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "klijent.izmeni") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID klijenta", http.StatusBadRequest) @@ -195,6 +206,11 @@ func (h *Handler) SacuvajIzmenuKlijenta(w http.ResponseWriter, r *http.Request) // ObrisiKlijenta prima POST zahtev i briše klijenta po ID-u func (h *Handler) ObrisiKlijenta(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "klijent.obrisi") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID klijenta", http.StatusBadRequest) diff --git a/internal/handler/magacin.go b/internal/handler/magacin.go index 4245e67..4b8692a 100644 --- a/internal/handler/magacin.go +++ b/internal/handler/magacin.go @@ -6,6 +6,7 @@ import ( "ntech/internal/db" "ntech/internal/db/sqlite" + "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -74,6 +75,11 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) { // ObrisiArtikal briše artikal po ID-u func (h *Handler) ObrisiArtikal(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "artikal.obrisi") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { diff --git a/internal/handler/magacin_forma.go b/internal/handler/magacin_forma.go index 861d34c..201f830 100644 --- a/internal/handler/magacin_forma.go +++ b/internal/handler/magacin_forma.go @@ -6,6 +6,7 @@ import ( "strconv" "ntech/internal/db/sqlite" + "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -47,6 +48,11 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) { // SacuvajArtikal prima POST formu i čuva novi artikal func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "artikal.dodaj") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } if err := r.ParseForm(); err != nil { http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) return @@ -136,6 +142,11 @@ func (h *Handler) IzmeniArtikal(w http.ResponseWriter, r *http.Request) { // SacuvajIzmenuArtikla prima POST formu i čuva izmenu artikla func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "artikal.izmeni") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { diff --git a/internal/handler/nabavka.go b/internal/handler/nabavka.go index c76d263..efa36e0 100644 --- a/internal/handler/nabavka.go +++ b/internal/handler/nabavka.go @@ -9,6 +9,7 @@ import ( "ntech/internal/db" "ntech/internal/db/sqlite" + "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -121,6 +122,11 @@ func (h *Handler) NovaNabavka(w http.ResponseWriter, r *http.Request) { // SacuvajNabavku prima POST formu, parsira stavke i upisuje nabavku u bazu func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "nabavka.dodaj") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } if err := r.ParseForm(); err != nil { http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) return @@ -205,6 +211,11 @@ func (h *Handler) DetaljiNabavke(w http.ResponseWriter, r *http.Request) { // ObrisiNabavku prima POST zahtev i briše nabavku po ID-u func (h *Handler) ObrisiNabavku(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "nabavka.obrisi") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID nabavke", http.StatusBadRequest) diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 9915806..89f9016 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -13,6 +13,7 @@ import ( "time" ntechsqlite "ntech/internal/db/sqlite" + "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -48,6 +49,11 @@ var validnoImeBackupa = regexp.MustCompile(`^ntech_\d{8}_\d{6}\.db$`) // Podesavanja renderuje stranicu podešavanja func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.pregled") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) @@ -107,6 +113,11 @@ func ucitajListuBackupa() []BackupInfo { // VratiBackup zamenjuje trenutnu bazu sa izabranim backup fajlom func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "backup.pokreni") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } if err := r.ParseForm(); err != nil { http.Redirect(w, r, "/podesavanja?backup_greska=Greška+pri+čitanju+zahteva", http.StatusSeeOther) return @@ -187,6 +198,11 @@ func kopiraFajl(izvor, odrediste string) error { // SacuvajPodesavanja prima POST i čuva podešavanja u bazu func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.izmeni") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } if err := r.ParseForm(); err != nil { http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) return @@ -217,6 +233,11 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { // BackupBaze kreira konzistentnu kopiju baze i šalje je kao attachment func (h *Handler) BackupBaze(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "backup.pregled") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } privremeni := fmt.Sprintf("%s/ntech_backup_%s.db", os.TempDir(), time.Now().Format("20060102_150405")) if _, err := h.DB.ExecContext(r.Context(), "VACUUM INTO ?", privremeni); err != nil { @@ -233,6 +254,11 @@ func (h *Handler) BackupBaze(w http.ResponseWriter, r *http.Request) { // OtpremiLogo prima multipart upload slike loga i čuva je u web/static/uploads/ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.izmeni") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } // ograničavamo telo zahteva na 2MB + malo za zaglavlja forme r.Body = http.MaxBytesReader(w, r.Body, 2<<20+4096) if err := r.ParseMultipartForm(2 << 20); err != nil { diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index dbdb09d..f4b2721 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -12,6 +12,7 @@ import ( appdb "ntech/internal/db" "ntech/internal/db/sqlite" + "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -135,6 +136,11 @@ func (h *Handler) NovaProdaja(w http.ResponseWriter, r *http.Request) { // SacuvajProdaju prima POST formu, parsira stavke i upisuje prodajni nalog u bazu func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "prodaja.dodaj") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } if err := r.ParseForm(); err != nil { http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) return @@ -300,6 +306,11 @@ func (h *Handler) StampaProdaje(w http.ResponseWriter, r *http.Request) { // ObrisiProdaju prima POST zahtev, vraća stanje na magacin i briše nalog func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "prodaja.obrisi") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 655c679..89fc4ca 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -8,6 +8,7 @@ import ( "time" "ntech/internal/db/sqlite" + "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -109,6 +110,11 @@ func (h *Handler) NoviNalog(w http.ResponseWriter, r *http.Request) { // SacuvajNalog prima POST formu i upisuje novi servisni nalog u bazu func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.dodaj") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } if err := r.ParseForm(); err != nil { http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) return @@ -194,6 +200,11 @@ func (h *Handler) IzmeniNalog(w http.ResponseWriter, r *http.Request) { // SacuvajIzmenaNaloga prima POST formu i ažurira postojeći servisni nalog func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.izmeni") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) @@ -248,6 +259,11 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) { // ObrisiNalog prima POST zahtev i briše servisni nalog po ID-u func (h *Handler) ObrisiNalog(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.obrisi") { + http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden) + return + } id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index f02d2f1..5b46b6e 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -88,12 +88,37 @@ func RequireSuperAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { k := KorisnikIzKonteksta(r.Context()) if k == nil || k.Uloga != "superadmin" { - http.Error(w, "Pristup odbijen", http.StatusForbidden) + postaviFlashGresku(w, "Nemate dozvolu za ovu stranicu.") + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) return } next.ServeHTTP(w, r) }) } +// RequireAdmin je middleware koji propušta admin i superadmin korisnike +func RequireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + k := KorisnikIzKonteksta(r.Context()) + if k == nil || (k.Uloga != "admin" && k.Uloga != "superadmin") { + postaviFlashGresku(w, "Nemate dozvolu za ovu stranicu.") + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) +} + +// postaviFlashGresku upisuje jednokratnu poruku o grešci u kolačić +func postaviFlashGresku(w http.ResponseWriter, poruka string) { + http.SetCookie(w, &http.Cookie{ + Name: "ntech_flash_greska", + Value: poruka, + Path: "/", + MaxAge: 60, + HttpOnly: true, + }) +} + // ErrNijePrijavljen se vraća kada korisnik nije u contextu var ErrNijePrijavljen = errors.New("korisnik nije prijavljen") diff --git a/internal/middleware/dozvole.go b/internal/middleware/dozvole.go new file mode 100644 index 0000000..6496253 --- /dev/null +++ b/internal/middleware/dozvole.go @@ -0,0 +1,147 @@ +package middleware + +// sve poznate akcije u sistemu +var sveAkcije = []string{ + "artikal.pregled", + "artikal.dodaj", + "artikal.izmeni", + "artikal.obrisi", + "artikal.premesti", + "kategorija.pregled", + "kategorija.dodaj", + "kategorija.izmeni", + "kategorija.obrisi", + "nabavka.pregled", + "nabavka.dodaj", + "nabavka.obrisi", + "dobavljac.pregled", + "dobavljac.dodaj", + "dobavljac.izmeni", + "dobavljac.obrisi", + "servis.pregled", + "servis.dodaj", + "servis.izmeni", + "servis.obrisi", + "prodaja.pregled", + "prodaja.dodaj", + "prodaja.obrisi", + "klijent.pregled", + "klijent.dodaj", + "klijent.izmeni", + "klijent.obrisi", + "podsetnik.pregled", + "podsetnik.dodaj", + "podsetnik.izmeni", + "podsetnik.obrisi", + "izvestaj.pregled", + "podesavanja.pregled", + "podesavanja.izmeni", + "korisnik.pregled", + "korisnik.dodaj", + "korisnik.izmeni", + "korisnik.obrisi", + "korisnik.uloga", + "backup.pregled", + "backup.pokreni", +} + +// SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu +func SveAkcije() []string { + return sveAkcije +} + +// ImaDozvolu proverava da li data uloga sme da izvrši traženu akciju +func ImaDozvolu(uloga, akcija string) bool { + switch uloga { + case "superadmin": + // superadmin sme sve + return true + + case "admin": + switch akcija { + // artikal + case "artikal.pregled", "artikal.dodaj", "artikal.izmeni", + "artikal.obrisi", "artikal.premesti": + return true + // kategorija + case "kategorija.pregled", "kategorija.dodaj", + "kategorija.izmeni", "kategorija.obrisi": + return true + // nabavka + case "nabavka.pregled", "nabavka.dodaj", "nabavka.obrisi": + return true + // dobavljač + case "dobavljac.pregled", "dobavljac.dodaj", + "dobavljac.izmeni", "dobavljac.obrisi": + return true + // servis + case "servis.pregled", "servis.dodaj", + "servis.izmeni", "servis.obrisi": + return true + // prodaja + case "prodaja.pregled", "prodaja.dodaj", "prodaja.obrisi": + return true + // klijent + case "klijent.pregled", "klijent.dodaj", + "klijent.izmeni", "klijent.obrisi": + return true + // podsetnik + case "podsetnik.pregled", "podsetnik.dodaj", + "podsetnik.izmeni", "podsetnik.obrisi": + return true + // izveštaji i podešavanja + case "izvestaj.pregled", + "podesavanja.pregled", "podesavanja.izmeni": + return true + // korisnici (bez promene uloge) + case "korisnik.pregled", "korisnik.dodaj", + "korisnik.izmeni", "korisnik.obrisi": + return true + // backup + case "backup.pregled", "backup.pokreni": + return true + } + return false + + case "radnik": + switch akcija { + // artikal — bez brisanja i premeštanja + case "artikal.pregled", "artikal.dodaj", "artikal.izmeni": + return true + // kategorija — samo pregled + case "kategorija.pregled": + return true + // nabavka — bez brisanja + case "nabavka.pregled", "nabavka.dodaj": + return true + // dobavljač — bez brisanja + case "dobavljac.pregled", "dobavljac.dodaj", "dobavljac.izmeni": + return true + // servis — bez brisanja + case "servis.pregled", "servis.dodaj", "servis.izmeni": + return true + // prodaja — bez brisanja + case "prodaja.pregled", "prodaja.dodaj": + return true + // klijent — bez brisanja + case "klijent.pregled", "klijent.dodaj", "klijent.izmeni": + return true + // podsetnik — sve + case "podsetnik.pregled", "podsetnik.dodaj", + "podsetnik.izmeni", "podsetnik.obrisi": + return true + } + return false + } + + return false +} + +// SveDozvole vraća mapu svih akcija sa vrednošću true/false za datu ulogu +func SveDozvole(uloga string) map[string]bool { + m := make(map[string]bool, len(sveAkcije)) + for _, akcija := range sveAkcije { + m[akcija] = ImaDozvolu(uloga, akcija) + } + return m +} diff --git a/internal/model/stranica.go b/internal/model/stranica.go index 9aa0f57..45f3f79 100644 --- a/internal/model/stranica.go +++ b/internal/model/stranica.go @@ -34,8 +34,9 @@ type PodaciStranice struct { LogoPutanja string // putanja do slike, koristi se samo kada je LogoTip "slika" Korisnik string KorisnikIme string // korisničko ime prijavljenog korisnika - KorisnikUloga string // uloga: "superadmin", "admin", "radnik" - CsrfToken string // CSRF zaštitni token za forme + KorisnikUloga string // uloga: "superadmin", "admin", "radnik" + CsrfToken string // CSRF zaštitni token za forme + Dozvole map[string]bool // mapa akcija → dozvoljeno/nije } // PodaciDashboarda su podaci specifični za dashboard stranicu @@ -49,4 +50,5 @@ type PodaciDashboarda struct { PoslednjiServisi []StavkaServisa KriticneZalihe []StavkaZalihe PoslednjeProdaje []StavkaProdajePregled + FlashGreska string } diff --git a/migrations/021_dozvole.sql b/migrations/021_dozvole.sql new file mode 100644 index 0000000..2eec714 --- /dev/null +++ b/migrations/021_dozvole.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS dozvole ( + uloga TEXT NOT NULL, + akcija TEXT NOT NULL, + dozvoljeno INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (uloga, akcija) +); diff --git a/web/static/css/main.css b/web/static/css/main.css index 8ebb6cb..2b15dc0 100644 --- a/web/static/css/main.css +++ b/web/static/css/main.css @@ -316,11 +316,75 @@ body { border-radius: 6px; padding: 4px 10px; font-size: 12px; + font-weight: 500; text-decoration: none; transition: opacity 0.2s; } .btn-primarno-malo:hover { opacity: 0.85; } +/* sekundarno dugme (Odustani) */ +.btn-sekundarno { + display: inline-flex; + align-items: center; + background: transparent; + color: var(--tekst-sporedni); + border: 0.5px solid var(--ivica); + border-radius: 8px; + padding: 9px 20px; + font-size: 14px; + cursor: pointer; + text-decoration: none; + transition: background 0.2s, color 0.2s; +} +.btn-sekundarno:hover { background: var(--pozadina); color: var(--tekst-glavni); } + +/* crveno dugme za brisanje u tabelama */ +.btn-obrisi-malo { + display: inline-flex; + align-items: center; + background: #dc2626; + color: #fff; + border: none; + border-radius: 6px; + padding: 4px 10px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: opacity 0.2s; +} +.btn-obrisi-malo:hover { opacity: 0.8; } + +/* nazad link na formama */ +.nazad-link { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--tekst-sporedni); + text-decoration: none; + margin-bottom: 20px; + transition: color 0.2s; +} +.nazad-link:hover { color: var(--tekst-glavni); } + +/* hover na redovima tabela — zamena za inline onmouseover/onmouseout */ +.red-tabele { + border-bottom: 0.5px solid var(--ivica); + transition: background 0.15s; +} +.red-tabele:hover { background: var(--pozadina); } + +/* naslov sekcije unutar forme */ +.sekcija-naslov { + font-size: 12px; + font-weight: 500; + color: var(--tekst-sporedni); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 10px; +} + /* avatar krug korisnika u topbaru */ .avatar-korisnik { width: 32px; @@ -423,6 +487,7 @@ select { /* mobilni ekrani */ @media (max-width: 768px) { + /* sidebar — drawer ponašanje */ .sidebar { position: fixed; left: 0; @@ -433,60 +498,32 @@ select { transition: transform 0.28s cubic-bezier(.4,0,.2,1); width: 220px !important; } + .sidebar.otvoren { transform: translateX(0); } + .sidebar.otvoren .logo-zona { opacity: 1; width: 160px; pointer-events: auto; } + .sidebar.otvoren .nav-oznaka { opacity: 1; } + .sidebar.otvoren .nav-stavka span { opacity: 1; pointer-events: auto; } + .nav-tooltip { display: none !important; } + .glavni-sadrzaj { width: 100%; } - .sidebar.otvoren { - transform: translateX(0); - } + /* topbar hamburger */ + #hamburger-topbar { display: flex !important; color: var(--tekst-glavni); } + #hamburger-topbar:hover { background: var(--pozadina); } - /* na mobilnom logo zona uvek vidljiva kada je sidebar otvoren */ - .sidebar.otvoren .logo-zona { - opacity: 1; - width: 160px; - pointer-events: auto; - } + /* teme */ + .topbar-teme { display: none; } + .teme-grid { flex-direction: column !important; } - .sidebar.otvoren .nav-oznaka { - opacity: 1; - } + /* forme */ + .forma-grid-2 { grid-template-columns: 1fr !important; } - .sidebar.otvoren .nav-stavka span { - opacity: 1; - pointer-events: auto; - } - - /* glavni sadržaj zauzima celu širinu */ - .glavni-sadrzaj { - width: 100%; - } - - /* sakrij tooltip na mobilnom */ - .nav-tooltip { - display: none !important; - } -} - -/* hamburger u topbaru — samo na mobilnom */ -@media (max-width: 768px) { - #hamburger-topbar { - display: flex !important; - color: var(--tekst-glavni); - } - - #hamburger-topbar:hover { - background: var(--pozadina); - } -} - -/* tačkice za teme u topbaru — sakrij na mobilnom */ -@media (max-width: 768px) { - .topbar-teme { - display: none; - } - - /* teme u podešavanjima — jedna ispod druge na mobilnom */ - .teme-grid { - flex-direction: column !important; - } + /* magacin traka */ + .magacin-traka { flex-direction: column; align-items: stretch; } + .magacin-traka form { flex-direction: column; align-items: stretch; } + .magacin-traka a { text-align: center; } + .magacin-traka form input, + .magacin-traka form select, + .magacin-traka form button, + .magacin-traka form label { width: 100%; justify-content: flex-start; } } /* tačkice za teme */ @@ -507,13 +544,6 @@ select { border-color: var(--tekst-glavni) !important; } -/* forme — responsive */ -@media (max-width: 768px) { - .forma-grid-2 { - grid-template-columns: 1fr !important; - } -} - /* animacije */ @keyframes fadeInUp { from { opacity: 0; transform: translateY(10px); } @@ -553,30 +583,3 @@ select { min-width: 200px; flex-wrap: wrap; } - -@media (max-width: 768px) { - .magacin-traka { - flex-direction: column; - align-items: stretch; - } - - .magacin-traka form { - flex-direction: column; - align-items: stretch; - } - - .magacin-traka a { - text-align: center; - } -} - -/* dodatna korekcija magacin trake */ -@media (max-width: 768px) { - .magacin-traka form input, - .magacin-traka form select, - .magacin-traka form button, - .magacin-traka form label { - width: 100%; - justify-content: flex-start; - } -} diff --git a/web/templates/komponente/sidebar.html b/web/templates/komponente/sidebar.html index 5885f48..30034f4 100644 --- a/web/templates/komponente/sidebar.html +++ b/web/templates/komponente/sidebar.html @@ -75,11 +75,13 @@ Dobavljači + {{if index .Dozvole "izvestaj.pregled"}} Izveštaji Izveštaji + {{end}} @@ -90,7 +92,7 @@ Moj profil - {{if eq .KorisnikUloga "superadmin"}} + {{if or (eq .KorisnikUloga "superadmin") (eq .KorisnikUloga "admin")}} Korisnici @@ -98,14 +100,24 @@ {{end}} + {{if eq .KorisnikUloga "superadmin"}} + + + Dozvole + Dozvole + + {{end}} + + {{if index .Dozvole "podesavanja.pregled"}} Podešavanja Podešavanja + {{end}} @@ -117,5 +128,48 @@ + +
+ {{range .Artikli}} +
+
+
+
{{.Naziv}}
+ {{if .KategorijaNaziv}} +
{{.KategorijaNaziv}}
+ {{end}} +
+
+ Izmeni + {{if index $.Dozvole "artikal.obrisi"}} + + Obriši + + {{end}} +
+
+
+
+ Količina: + {{.Kolicina}} +
+
+ Cena: {{printf "%.0f" .ProdajnaCena}} din +
+ {{if .Lokacija}} +
+ Lokacija: {{.Lokacija}} +
+ {{end}} +
+
+ {{else}} +
+ Nema artikala. Dodaj prvi artikal. +
+ {{end}} +
+ {{end}} diff --git a/web/templates/stranice/magacin_forma.html b/web/templates/stranice/magacin_forma.html index 32ff966..f757e3d 100644 --- a/web/templates/stranice/magacin_forma.html +++ b/web/templates/stranice/magacin_forma.html @@ -2,12 +2,18 @@ {{define "naslov"}}{{if .Izmena}}Izmeni artikal{{else}}Novi artikal{{end}} — NTech{{end}} +{{define "dodatni-css"}} + +{{end}} + {{define "sadrzaj"}}
- - + + Nazad na magacin @@ -19,9 +25,7 @@
{{if .Greska}} -
- {{.Greska}} -
+
{{.Greska}}
{{end}} @@ -91,12 +95,8 @@
- - Odustani - -
diff --git a/web/templates/stranice/nabavka_detalji.html b/web/templates/stranice/nabavka_detalji.html index 3cd4724..fe77932 100644 --- a/web/templates/stranice/nabavka_detalji.html +++ b/web/templates/stranice/nabavka_detalji.html @@ -31,10 +31,8 @@
- - + + Nazad na nabavke @@ -86,9 +84,7 @@ {{range .Stavke}} - + {{.ArtikalNaziv}} {{.Kolicina}} {{printf "%.2f" .CenaPoKomadu}} din @@ -152,10 +148,8 @@
- diff --git a/web/templates/stranice/nabavka_forma.html b/web/templates/stranice/nabavka_forma.html index ab7dea0..80f2c61 100644 --- a/web/templates/stranice/nabavka_forma.html +++ b/web/templates/stranice/nabavka_forma.html @@ -18,7 +18,7 @@ @media (max-width: 768px) { .stavke-tabela-wrapper { display: none; } - .stavke-kartice { display: flex !important; } + .stavke-kartice { display: flex !important; } } {{end}} @@ -84,7 +84,7 @@ params.append('naziv', this.modalNaziv.trim()); if (this.modalKategorijaID) params.append('kategorija_id', this.modalKategorijaID); if (this.modalCena) params.append('prodajna_cena', this.modalCena); - params.append('_csrf', document.querySelector('meta[name="csrf-token"]')?.content || ''); + params.append('_csrf', document.querySelector('meta[name=csrf-token]')?.content || ''); try { const odgovor = await fetch('/magacin/novi', { @@ -113,20 +113,15 @@ }"> - - + + Nazad na nabavke
{{if .Greska}} -
- {{.Greska}} -
+
{{.Greska}}
{{end}} @@ -275,16 +270,8 @@
- - Odustani - - + Odustani +
@@ -301,14 +288,11 @@
Novi artikal - +
-
+
@@ -340,15 +324,9 @@
- - +
diff --git a/web/templates/stranice/nabavke.html b/web/templates/stranice/nabavke.html index f7d89e3..cb4d527 100644 --- a/web/templates/stranice/nabavke.html +++ b/web/templates/stranice/nabavke.html @@ -4,14 +4,7 @@ {{define "dodatni-css"}} {{end}} @@ -68,20 +68,15 @@ }"> - - + + Nazad na prodaju
{{if .Greska}} -
- {{.Greska}} -
+
{{.Greska}}
{{end}} @@ -246,17 +241,10 @@
- - Odustani - -
diff --git a/web/templates/stranice/servis.html b/web/templates/stranice/servis.html index 06675cc..7d75120 100644 --- a/web/templates/stranice/servis.html +++ b/web/templates/stranice/servis.html @@ -4,11 +4,6 @@ {{define "dodatni-css"}} @@ -26,10 +16,8 @@
- - + + Nazad na servis @@ -44,10 +32,7 @@
{{if .Greska}} -
- {{.Greska}} -
+
{{.Greska}}
{{end}} @@ -164,14 +149,8 @@
- - Odustani - -
diff --git a/web/templates/teme/podrazumevana/base.html b/web/templates/teme/podrazumevana/base.html index ebc0c53..cac0193 100644 --- a/web/templates/teme/podrazumevana/base.html +++ b/web/templates/teme/podrazumevana/base.html @@ -102,6 +102,15 @@ i.type = 'hidden'; i.name = '_csrf'; i.value = m.content; f.appendChild(i); }); + + // data-potvrda: sigurna alternativa za onclick=confirm() na dugmadima i linkovima + // Vrednost atributa je poruka koja se prikazuje korisniku. Go template je HTML-escape-uje, + // JS čita originalnu vrednost — nema problema sa specijalnim karakterima u imenima. + document.querySelectorAll('[data-potvrda]').forEach(function(el) { + el.addEventListener('click', function(e) { + if (!confirm(el.getAttribute('data-potvrda'))) e.preventDefault(); + }); + }); });