Ispravka QR koda za 2FA — generisanje na serveru kao base64 PNG
This commit is contained in:
+140
-46
@@ -1,13 +1,19 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"ntech/internal/config"
|
"ntech/internal/config"
|
||||||
"ntech/internal/db/sqlite"
|
"ntech/internal/db/sqlite"
|
||||||
"ntech/internal/handler"
|
"ntech/internal/handler"
|
||||||
|
ntechmw "ntech/internal/middleware"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -42,6 +48,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Println("Migracije uspešno izvršene")
|
log.Println("Migracije uspešno izvršene")
|
||||||
|
|
||||||
|
napraviStartupBackup(putanjaBaze)
|
||||||
|
|
||||||
|
// periodično brisanje isteklih sesija
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
sesijeRepo := sqlite.NoviSesijeRepo(db)
|
||||||
|
for range ticker.C {
|
||||||
|
_ = sesijeRepo.ObrisiIstekle(nil)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
h := handler.Novi(db)
|
h := handler.Novi(db)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -49,53 +67,79 @@ func main() {
|
|||||||
// statični fajlovi
|
// statični fajlovi
|
||||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
|
||||||
|
|
||||||
// rute
|
// javne rute (bez autentifikacije)
|
||||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/prijava", h.PrikazPrijave)
|
||||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
r.Post("/prijava", h.Prijava)
|
||||||
|
r.Get("/prijava/totp", h.PrikazTotp)
|
||||||
|
r.Post("/prijava/totp", h.VerifikujTotp)
|
||||||
|
r.Get("/setup", h.PrikazSetupa)
|
||||||
|
r.Post("/setup", h.SacuvajSetup)
|
||||||
|
r.Get("/odjava", h.Odjava)
|
||||||
|
|
||||||
|
// zaštićene rute — zahtevaju prijavljenog korisnika
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(ntechmw.RequireAuth(db))
|
||||||
|
|
||||||
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||||
|
})
|
||||||
|
r.Get("/dashboard", h.Dashboard)
|
||||||
|
r.Get("/podesavanja", h.Podesavanja)
|
||||||
|
r.Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja)
|
||||||
|
r.Get("/podesavanja/backup", h.BackupBaze)
|
||||||
|
r.Get("/tema/{tema}", h.PromeniTemu)
|
||||||
|
r.Get("/magacin", h.Magacin)
|
||||||
|
r.Get("/magacin/novi", h.NoviArtikal)
|
||||||
|
r.Post("/magacin/novi", h.SacuvajArtikal)
|
||||||
|
r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal)
|
||||||
|
r.Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla)
|
||||||
|
r.Get("/magacin/obrisi/{id}", h.ObrisiArtikal)
|
||||||
|
r.Get("/magacin/kategorije", h.Kategorije)
|
||||||
|
r.Post("/magacin/kategorije/dodaj", h.DodajKategoriju)
|
||||||
|
r.Get("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju)
|
||||||
|
r.Get("/nabavke", h.Nabavke)
|
||||||
|
r.Get("/nabavke/nova", h.NovaNabavka)
|
||||||
|
r.Post("/nabavke/nova", h.SacuvajNabavku)
|
||||||
|
r.Get("/nabavke/{id}", h.DetaljiNabavke)
|
||||||
|
r.Post("/nabavke/obrisi/{id}", h.ObrisiNabavku)
|
||||||
|
r.Get("/dobavljaci", h.Dobavljaci)
|
||||||
|
r.Get("/dobavljaci/novi", h.NoviDobavljac)
|
||||||
|
r.Post("/dobavljaci/novi", h.SacuvajDobavljaca)
|
||||||
|
r.Get("/dobavljaci/izmeni/{id}", h.IzmeniDobavljaca)
|
||||||
|
r.Post("/dobavljaci/izmeni/{id}", h.SacuvajIzmeneDobavljaca)
|
||||||
|
r.Post("/dobavljaci/obrisi/{id}", h.ObrisiDobavljaca)
|
||||||
|
r.Get("/klijenti", h.Klijenti)
|
||||||
|
r.Get("/klijenti/novi", h.NoviKlijent)
|
||||||
|
r.Post("/klijenti/novi", h.SacuvajKlijenta)
|
||||||
|
r.Get("/klijenti/izmeni/{id}", h.IzmeniKlijenta)
|
||||||
|
r.Post("/klijenti/izmeni/{id}", h.SacuvajIzmenuKlijenta)
|
||||||
|
r.Post("/klijenti/obrisi/{id}", h.ObrisiKlijenta)
|
||||||
|
r.Get("/servis", h.Servis)
|
||||||
|
r.Get("/servis/novi", h.NoviNalog)
|
||||||
|
r.Post("/servis/novi", h.SacuvajNalog)
|
||||||
|
r.Get("/servis/izmeni/{id}", h.IzmeniNalog)
|
||||||
|
r.Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga)
|
||||||
|
r.Post("/servis/obrisi/{id}", h.ObrisiNalog)
|
||||||
|
r.Get("/servis/{id}", h.DetaljiNaloga)
|
||||||
|
r.Get("/izvestaji", h.Izvestaji)
|
||||||
|
r.Get("/prodaja", h.Prodaja)
|
||||||
|
r.Get("/prodaja/nova", h.NovaProdaja)
|
||||||
|
r.Post("/prodaja/nova", h.SacuvajProdaju)
|
||||||
|
r.Post("/prodaja/obrisi/{id}", h.ObrisiProdaju)
|
||||||
|
r.Get("/prodaja/{id}/stampa", h.StampaProdaje)
|
||||||
|
r.Get("/prodaja/{id}", h.DetaljiProdaje)
|
||||||
|
|
||||||
|
// admin rute
|
||||||
|
r.Get("/admin/korisnici", h.AdminKorisnici)
|
||||||
|
r.Post("/admin/korisnici/novi", h.AdminSacuvajKorisnika)
|
||||||
|
r.Post("/admin/korisnici/{id}/aktivan", h.AdminToggleAktivan)
|
||||||
|
r.Post("/admin/korisnici/{id}/uloga", h.AdminPromeniUlogu)
|
||||||
|
r.Get("/admin/profil", h.AdminProfil)
|
||||||
|
r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku)
|
||||||
|
r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni)
|
||||||
|
r.Post("/admin/profil/totp/aktiviraj", h.AdminTotpAktivacija)
|
||||||
|
r.Post("/admin/profil/totp/deaktiviraj", h.AdminTotpDeaktivacija)
|
||||||
})
|
})
|
||||||
r.Get("/dashboard", h.Dashboard)
|
|
||||||
r.Get("/podesavanja", h.Podesavanja)
|
|
||||||
r.Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja)
|
|
||||||
r.Get("/tema/{tema}", h.PromeniTemu)
|
|
||||||
r.Get("/magacin", h.Magacin)
|
|
||||||
r.Get("/magacin/novi", h.NoviArtikal)
|
|
||||||
r.Post("/magacin/novi", h.SacuvajArtikal)
|
|
||||||
r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal)
|
|
||||||
r.Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla)
|
|
||||||
r.Get("/magacin/obrisi/{id}", h.ObrisiArtikal)
|
|
||||||
r.Get("/magacin/kategorije", h.Kategorije)
|
|
||||||
r.Post("/magacin/kategorije/dodaj", h.DodajKategoriju)
|
|
||||||
r.Get("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju)
|
|
||||||
r.Get("/nabavke", h.Nabavke)
|
|
||||||
r.Get("/nabavke/nova", h.NovaNabavka)
|
|
||||||
r.Post("/nabavke/nova", h.SacuvajNabavku)
|
|
||||||
r.Get("/nabavke/{id}", h.DetaljiNabavke)
|
|
||||||
r.Post("/nabavke/obrisi/{id}", h.ObrisiNabavku)
|
|
||||||
r.Get("/dobavljaci", h.Dobavljaci)
|
|
||||||
r.Get("/dobavljaci/novi", h.NoviDobavljac)
|
|
||||||
r.Post("/dobavljaci/novi", h.SacuvajDobavljaca)
|
|
||||||
r.Get("/dobavljaci/izmeni/{id}", h.IzmeniDobavljaca)
|
|
||||||
r.Post("/dobavljaci/izmeni/{id}", h.SacuvajIzmeneDobavljaca)
|
|
||||||
r.Post("/dobavljaci/obrisi/{id}", h.ObrisiDobavljaca)
|
|
||||||
r.Get("/klijenti", h.Klijenti)
|
|
||||||
r.Get("/klijenti/novi", h.NoviKlijent)
|
|
||||||
r.Post("/klijenti/novi", h.SacuvajKlijenta)
|
|
||||||
r.Get("/klijenti/izmeni/{id}", h.IzmeniKlijenta)
|
|
||||||
r.Post("/klijenti/izmeni/{id}", h.SacuvajIzmenuKlijenta)
|
|
||||||
r.Post("/klijenti/obrisi/{id}", h.ObrisiKlijenta)
|
|
||||||
r.Get("/servis", h.Servis)
|
|
||||||
r.Get("/servis/novi", h.NoviNalog)
|
|
||||||
r.Post("/servis/novi", h.SacuvajNalog)
|
|
||||||
r.Get("/servis/izmeni/{id}", h.IzmeniNalog)
|
|
||||||
r.Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga)
|
|
||||||
r.Post("/servis/obrisi/{id}", h.ObrisiNalog)
|
|
||||||
r.Get("/servis/{id}", h.DetaljiNaloga)
|
|
||||||
r.Get("/prodaja", h.Prodaja)
|
|
||||||
r.Get("/prodaja/nova", h.NovaProdaja)
|
|
||||||
r.Post("/prodaja/nova", h.SacuvajProdaju)
|
|
||||||
r.Post("/prodaja/obrisi/{id}", h.ObrisiProdaju)
|
|
||||||
r.Get("/prodaja/{id}/stampa", h.StampaProdaje)
|
|
||||||
r.Get("/prodaja/{id}", h.DetaljiProdaje)
|
|
||||||
|
|
||||||
log.Printf("NTech pokrenut na portu %s", port)
|
log.Printf("NTech pokrenut na portu %s", port)
|
||||||
err = http.ListenAndServe(":"+port, r)
|
err = http.ListenAndServe(":"+port, r)
|
||||||
@@ -103,3 +147,53 @@ func main() {
|
|||||||
log.Fatalf("Greška: port %s je zauzet ili nije dostupan", port)
|
log.Fatalf("Greška: port %s je zauzet ili nije dostupan", port)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// napraviStartupBackup kreira kopiju baze pri pokretanju i čuva poslednjih 7
|
||||||
|
func napraviStartupBackup(putanjaBaze string) {
|
||||||
|
if _, err := os.Stat(putanjaBaze); os.IsNotExist(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folder := "backups"
|
||||||
|
if err := os.MkdirAll(folder, 0755); err != nil {
|
||||||
|
log.Printf("backup: ne mogu kreirati folder: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ime := fmt.Sprintf("ntech_%s.db", time.Now().Format("20060102_150405"))
|
||||||
|
odrediste := filepath.Join(folder, ime)
|
||||||
|
|
||||||
|
src, err := os.Open(putanjaBaze)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("backup: ne mogu otvoriti bazu: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
dst, err := os.Create(odrediste)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("backup: ne mogu kreirati backup fajl: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, src); err != nil {
|
||||||
|
log.Printf("backup: greška pri kopiranju: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Backup kreiran: %s", odrediste)
|
||||||
|
ocistiStareBackupe(folder, 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ocistiStareBackupe briše najstarije backup fajlove ako ih ima više od max
|
||||||
|
func ocistiStareBackupe(folder string, max int) {
|
||||||
|
fajlovi, err := filepath.Glob(filepath.Join(folder, "ntech_*.db"))
|
||||||
|
if err != nil || len(fajlovi) <= max {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Strings(fajlovi)
|
||||||
|
for _, f := range fajlovi[:len(fajlovi)-max] {
|
||||||
|
_ = os.Remove(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ module ntech
|
|||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/go-chi/chi/v5 v5.3.0 // indirect
|
github.com/go-chi/chi/v5 v5.3.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/joho/godotenv v1.5.1 // indirect
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/pquerna/otp v1.5.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 // indirect
|
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/crypto v0.52.0 // indirect
|
||||||
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
modernc.org/libc v1.72.3 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
@@ -10,13 +13,22 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
|
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
|
||||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
|
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
|
||||||
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"image/png"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bcryptCost = 12
|
||||||
|
|
||||||
|
// HashujLozinku kreira bcrypt heš lozinke
|
||||||
|
func HashujLozinku(lozinka string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(lozinka), bcryptCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ntech: auth.HashujLozinku: %w", err)
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProveriLozinku proverava da li lozinka odgovara heš vrednosti
|
||||||
|
func ProveriLozinku(hash, lozinka string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(lozinka)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerisiToken generiše nasumičan UUID token za sesiju
|
||||||
|
func GenerisiToken() string {
|
||||||
|
return uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotpPodaci sadrži tajnu, URI i base64-enkodiran QR kod za prikaz
|
||||||
|
type TotpPodaci struct {
|
||||||
|
Tajna string
|
||||||
|
URI string
|
||||||
|
QRBase64 string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerisuTotpTajnu generiše novu TOTP tajnu za korisnika
|
||||||
|
func GenerisuTotpTajnu(korisnickoIme, nazivFirme string) (*TotpPodaci, error) {
|
||||||
|
if nazivFirme == "" {
|
||||||
|
nazivFirme = "NTech"
|
||||||
|
}
|
||||||
|
kljuc, err := totp.Generate(totp.GenerateOpts{
|
||||||
|
Issuer: nazivFirme,
|
||||||
|
AccountName: korisnickoIme,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: auth.GenerisuTotpTajnu: %w", err)
|
||||||
|
}
|
||||||
|
img, err := kljuc.Image(200, 200)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: auth.GenerisuTotpTajnu: qr: %w", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: auth.GenerisuTotpTajnu: png: %w", err)
|
||||||
|
}
|
||||||
|
return &TotpPodaci{
|
||||||
|
Tajna: kljuc.Secret(),
|
||||||
|
URI: kljuc.URL(),
|
||||||
|
QRBase64: base64.StdEncoding.EncodeToString(buf.Bytes()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegenerisiTotpQR vraća URI i base64 QR sliku za već postojeću TOTP tajnu
|
||||||
|
func RegenerisiTotpQR(tajna, korisnickoIme, nazivFirme string) (uri, qrBase64 string) {
|
||||||
|
kljuc, err := totp.Generate(totp.GenerateOpts{
|
||||||
|
Issuer: nazivFirme,
|
||||||
|
AccountName: korisnickoIme,
|
||||||
|
Secret: []byte(tajna),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
img, err := kljuc.Image(200, 200)
|
||||||
|
if err != nil {
|
||||||
|
return kljuc.URL(), ""
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
return kljuc.URL(), ""
|
||||||
|
}
|
||||||
|
return kljuc.URL(), base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifikujTotpKod proverava TOTP kod korisnika
|
||||||
|
func VerifikujTotpKod(kod, tajna string) bool {
|
||||||
|
return totp.Validate(kod, tajna)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
)
|
)
|
||||||
@@ -74,3 +75,25 @@ type ProdajaRepository interface {
|
|||||||
Obrisi(ctx context.Context, id int64) error
|
Obrisi(ctx context.Context, id int64) error
|
||||||
SledeciBroj(ctx context.Context) (string, error)
|
SledeciBroj(ctx context.Context) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KorisniciRepository definiše operacije nad korisnicima
|
||||||
|
type KorisniciRepository interface {
|
||||||
|
Kreiraj(ctx context.Context, korisnickoIme, lozinkaHash, uloga string) (*model.Korisnik, error)
|
||||||
|
DohvatiPoImenu(ctx context.Context, korisnickoIme string) (*model.Korisnik, error)
|
||||||
|
DohvatiPoID(ctx context.Context, id int64) (*model.Korisnik, error)
|
||||||
|
Lista(ctx context.Context) ([]model.Korisnik, error)
|
||||||
|
AzurirajUlogu(ctx context.Context, id int64, uloga string) error
|
||||||
|
AzurirajAktivan(ctx context.Context, id int64, aktivan bool) error
|
||||||
|
PromeniLozinku(ctx context.Context, id int64, hash string) error
|
||||||
|
SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error
|
||||||
|
PostojiIjedan(ctx context.Context) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SesijeRepository definiše operacije nad sesijama
|
||||||
|
type SesijeRepository interface {
|
||||||
|
Kreiraj(ctx context.Context, korisnikID int64, token string, istice time.Time, totpPotvrdjeno bool) error
|
||||||
|
DohvatiPoTokenu(ctx context.Context, token string) (*model.Sesija, error)
|
||||||
|
PotvrdiTotp(ctx context.Context, token string, novoIstice time.Time) error
|
||||||
|
Obrisi(ctx context.Context, token string) error
|
||||||
|
ObrisiIstekle(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ntech/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqliteKorisniciRepo struct{ db *sql.DB }
|
||||||
|
|
||||||
|
// NoviKorisniciRepo kreira SQLite implementaciju KorisniciRepository
|
||||||
|
func NoviKorisniciRepo(db *sql.DB) *sqliteKorisniciRepo {
|
||||||
|
return &sqliteKorisniciRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteKorisniciRepo) Kreiraj(ctx context.Context, korisnickoIme, lozinkaHash, uloga string) (*model.Korisnik, error) {
|
||||||
|
res, err := r.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO korisnici (korisnicko_ime, lozinka_hash, uloga) VALUES (?, ?, ?)`,
|
||||||
|
korisnickoIme, lozinkaHash, uloga)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: korisnici.Kreiraj: %w", err)
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return r.DohvatiPoID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme string) (*model.Korisnik, error) {
|
||||||
|
k := &model.Korisnik{}
|
||||||
|
var aktivan int
|
||||||
|
var totpTajna sql.NullString
|
||||||
|
var datumKreiranja time.Time
|
||||||
|
err := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), datum_kreiranja
|
||||||
|
FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme).
|
||||||
|
Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &totpTajna, &datumKreiranja)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: korisnici.DohvatiPoImenu: %w", err)
|
||||||
|
}
|
||||||
|
k.Aktivan = aktivan == 1
|
||||||
|
k.TotpTajna = totpTajna.String
|
||||||
|
k.DatumKreiranja = datumKreiranja
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model.Korisnik, error) {
|
||||||
|
k := &model.Korisnik{}
|
||||||
|
var aktivan int
|
||||||
|
var datumKreiranja time.Time
|
||||||
|
err := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), datum_kreiranja
|
||||||
|
FROM korisnici WHERE id = ?`, id).
|
||||||
|
Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &k.TotpTajna, &datumKreiranja)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: korisnici.DohvatiPoID: %w", err)
|
||||||
|
}
|
||||||
|
k.Aktivan = aktivan == 1
|
||||||
|
k.DatumKreiranja = datumKreiranja
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT id, korisnicko_ime, lozinka_hash, uloga, aktivan, COALESCE(totp_tajna, ''), datum_kreiranja
|
||||||
|
FROM korisnici ORDER BY datum_kreiranja ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var lista []model.Korisnik
|
||||||
|
for rows.Next() {
|
||||||
|
var k model.Korisnik
|
||||||
|
var aktivan int
|
||||||
|
var datumKreiranja time.Time
|
||||||
|
if err := rows.Scan(&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &k.TotpTajna, &datumKreiranja); err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
|
||||||
|
}
|
||||||
|
k.Aktivan = aktivan == 1
|
||||||
|
k.DatumKreiranja = datumKreiranja
|
||||||
|
lista = append(lista, k)
|
||||||
|
}
|
||||||
|
return lista, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteKorisniciRepo) AzurirajUlogu(ctx context.Context, id int64, uloga string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `UPDATE korisnici SET uloga = ? WHERE id = ?`, uloga, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntech: korisnici.AzurirajUlogu: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteKorisniciRepo) AzurirajAktivan(ctx context.Context, id int64, aktivan bool) error {
|
||||||
|
val := 0
|
||||||
|
if aktivan {
|
||||||
|
val = 1
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `UPDATE korisnici SET aktivan = ? WHERE id = ?`, val, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntech: korisnici.AzurirajAktivan: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteKorisniciRepo) PromeniLozinku(ctx context.Context, id int64, hash string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `UPDATE korisnici SET lozinka_hash = ? WHERE id = ?`, hash, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntech: korisnici.PromeniLozinku: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteKorisniciRepo) SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error {
|
||||||
|
var err error
|
||||||
|
if tajna == "" {
|
||||||
|
_, err = r.db.ExecContext(ctx, `UPDATE korisnici SET totp_tajna = NULL WHERE id = ?`, id)
|
||||||
|
} else {
|
||||||
|
_, err = r.db.ExecContext(ctx, `UPDATE korisnici SET totp_tajna = ? WHERE id = ?`, tajna, id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntech: korisnici.SacuvajTotpTajnu: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteKorisniciRepo) PostojiIjedan(ctx context.Context) (bool, error) {
|
||||||
|
var broj int
|
||||||
|
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM korisnici`).Scan(&broj)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("ntech: korisnici.PostojiIjedan: %w", err)
|
||||||
|
}
|
||||||
|
return broj > 0, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ntech/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqliteSesijeRepo struct{ db *sql.DB }
|
||||||
|
|
||||||
|
// NoviSesijeRepo kreira SQLite implementaciju SesijeRepository
|
||||||
|
func NoviSesijeRepo(db *sql.DB) *sqliteSesijeRepo {
|
||||||
|
return &sqliteSesijeRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteSesijeRepo) Kreiraj(ctx context.Context, korisnikID int64, token string, istice time.Time, totpPotvrdjeno bool) error {
|
||||||
|
potvrdjen := 1
|
||||||
|
if !totpPotvrdjeno {
|
||||||
|
potvrdjen = 0
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO sesije (korisnik_id, token, totp_potvrdjeno, datum_isteka) VALUES (?, ?, ?, ?)`,
|
||||||
|
korisnikID, token, potvrdjen, istice)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntech: sesije.Kreiraj: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteSesijeRepo) DohvatiPoTokenu(ctx context.Context, token string) (*model.Sesija, error) {
|
||||||
|
s := &model.Sesija{}
|
||||||
|
var totpPotvrdjeno int
|
||||||
|
var datumIsteka, datumKreiranja time.Time
|
||||||
|
err := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, korisnik_id, token, totp_potvrdjeno, datum_isteka, datum_kreiranja
|
||||||
|
FROM sesije WHERE token = ?`, token).
|
||||||
|
Scan(&s.ID, &s.KorisnikID, &s.Token, &totpPotvrdjeno, &datumIsteka, &datumKreiranja)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: sesije.DohvatiPoTokenu: %w", err)
|
||||||
|
}
|
||||||
|
s.TotpPotvrdjeno = totpPotvrdjeno == 1
|
||||||
|
s.DatumIsteka = datumIsteka
|
||||||
|
s.DatumKreiranja = datumKreiranja
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteSesijeRepo) PotvrdiTotp(ctx context.Context, token string, novoIstice time.Time) error {
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE sesije SET totp_potvrdjeno = 1, datum_isteka = ? WHERE token = ?`,
|
||||||
|
novoIstice, token)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntech: sesije.PotvrdiTotp: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteSesijeRepo) Obrisi(ctx context.Context, token string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `DELETE FROM sesije WHERE token = ?`, token)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntech: sesije.Obrisi: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteSesijeRepo) ObrisiIstekle(ctx context.Context) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `DELETE FROM sesije WHERE datum_isteka < ?`, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntech: sesije.ObrisiIstekle: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"ntech/internal/auth"
|
||||||
|
"ntech/internal/db/sqlite"
|
||||||
|
"ntech/internal/middleware"
|
||||||
|
"ntech/internal/model"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type podaciAdminKorisnici struct {
|
||||||
|
model.PodaciStranice
|
||||||
|
Korisnici []model.Korisnik
|
||||||
|
Greska string
|
||||||
|
Sacuvano bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type podaciAdminProfil struct {
|
||||||
|
model.PodaciStranice
|
||||||
|
Greska string
|
||||||
|
Sacuvano string
|
||||||
|
TotpURI string
|
||||||
|
TotpTajna string
|
||||||
|
TotpQR template.URL
|
||||||
|
TotpAktivan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminKorisnici prikazuje listu korisnika
|
||||||
|
func (h *Handler) AdminKorisnici(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 = "admin"
|
||||||
|
ps.NaslovStranice = "Korisnici"
|
||||||
|
|
||||||
|
podaci := podaciAdminKorisnici{
|
||||||
|
PodaciStranice: ps,
|
||||||
|
Korisnici: lista,
|
||||||
|
Greska: r.URL.Query().Get("greska"),
|
||||||
|
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
renderujAdminTemplate(w, "web/templates/stranice/admin_korisnici.html", podaci)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSacuvajKorisnika kreira novog korisnika
|
||||||
|
func (h *Handler) AdminSacuvajKorisnika(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/korisnici?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ime := r.FormValue("korisnicko_ime")
|
||||||
|
lozinka := r.FormValue("lozinka")
|
||||||
|
uloga := r.FormValue("uloga")
|
||||||
|
|
||||||
|
validneUloge := map[string]bool{"superadmin": true, "admin": true, "radnik": true}
|
||||||
|
if len(ime) < 3 || len(lozinka) < 8 || !validneUloge[uloga] {
|
||||||
|
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// superadmin može kreirati samo admin i radnik (ne drugog superadmina) osim ako je jedini superadmin
|
||||||
|
if uloga == "superadmin" && k.Uloga != "superadmin" {
|
||||||
|
http.Error(w, "Pristup odbijen", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := auth.HashujLozinku(lozinka)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.KorisniciRepo.Kreiraj(r.Context(), ime, hash, uloga); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminToggleAktivan menja aktivan status korisnika
|
||||||
|
func (h *Handler) AdminToggleAktivan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||||
|
if !middleware.JeAdmin(k) {
|
||||||
|
http.Error(w, "Pristup odbijen", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := parseID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ne sme deaktivirati sam sebe
|
||||||
|
if id == k.ID {
|
||||||
|
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
korisnik, err := h.KorisniciRepo.DohvatiPoID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/korisnici?greska=1", 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
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPromeniUlogu menja ulogu korisnika
|
||||||
|
func (h *Handler) AdminPromeniUlogu(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/korisnici?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uloga := r.FormValue("uloga")
|
||||||
|
validneUloge := map[string]bool{"superadmin": true, "admin": true, "radnik": true}
|
||||||
|
if !validneUloge[uloga] {
|
||||||
|
http.Redirect(w, r, "/admin/korisnici?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.KorisniciRepo.AzurirajUlogu(r.Context(), id, uloga); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/korisnici?greska=2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/korisnici?sacuvano=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminProfil prikazuje stranicu profila
|
||||||
|
func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) {
|
||||||
|
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||||
|
if k == nil {
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// osvežavamo korisnika iz baze da bismo imali aktuelni totp_tajna
|
||||||
|
svezi, err := h.KorisniciRepo.DohvatiPoID(r.Context(), k.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Greška pri učitavanju profila", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||||
|
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||||
|
ps.Stranica = "admin"
|
||||||
|
ps.NaslovStranice = "Moj profil"
|
||||||
|
|
||||||
|
podaci := podaciAdminProfil{
|
||||||
|
PodaciStranice: ps,
|
||||||
|
Greska: r.URL.Query().Get("greska"),
|
||||||
|
Sacuvano: r.URL.Query().Get("sacuvano"),
|
||||||
|
TotpAktivan: svezi.TotpTajna != "",
|
||||||
|
}
|
||||||
|
|
||||||
|
renderujAdminTemplate(w, "web/templates/stranice/admin_profil.html", podaci)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPromeniLozinku menja lozinku prijavljenog korisnika
|
||||||
|
func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) {
|
||||||
|
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||||
|
if k == nil {
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/profil?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stara := r.FormValue("stara_lozinka")
|
||||||
|
nova := r.FormValue("nova_lozinka")
|
||||||
|
potvrda := r.FormValue("nova_lozinka_potvrda")
|
||||||
|
|
||||||
|
if !auth.ProveriLozinku(k.LozinkaHash, stara) {
|
||||||
|
http.Redirect(w, r, "/admin/profil?greska=lozinka", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(nova) < 8 || nova != potvrda {
|
||||||
|
http.Redirect(w, r, "/admin/profil?greska=lozinka2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := auth.HashujLozinku(nova)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.KorisniciRepo.PromeniLozinku(r.Context(), k.ID, hash); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/profil?sacuvano=lozinka", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminTotpPokreni generiše TOTP tajnu i prikazuje QR kod
|
||||||
|
func (h *Handler) AdminTotpPokreni(w http.ResponseWriter, r *http.Request) {
|
||||||
|
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||||
|
if k == nil {
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||||
|
totp, err := auth.GenerisuTotpTajnu(k.KorisnickoIme, podesavanja["naziv_firme"])
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||||
|
ps.Stranica = "admin"
|
||||||
|
ps.NaslovStranice = "Podesi 2FA"
|
||||||
|
|
||||||
|
podaci := podaciAdminProfil{
|
||||||
|
PodaciStranice: ps,
|
||||||
|
TotpURI: totp.URI,
|
||||||
|
TotpTajna: totp.Tajna,
|
||||||
|
TotpQR: template.URL("data:image/png;base64," + totp.QRBase64),
|
||||||
|
}
|
||||||
|
|
||||||
|
renderujAdminTemplate(w, "web/templates/stranice/admin_profil.html", podaci)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminTotpAktivacija verifikuje TOTP kod i čuva tajnu
|
||||||
|
func (h *Handler) AdminTotpAktivacija(w http.ResponseWriter, r *http.Request) {
|
||||||
|
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||||
|
if k == nil {
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/profil?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tajna := r.FormValue("totp_tajna")
|
||||||
|
kod := r.FormValue("kod")
|
||||||
|
|
||||||
|
if !auth.VerifikujTotpKod(kod, tajna) {
|
||||||
|
// ponovni prikaz sa greškom — regenerišemo isti tajnu
|
||||||
|
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||||
|
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||||
|
ps.Stranica = "admin"
|
||||||
|
ps.NaslovStranice = "Podesi 2FA"
|
||||||
|
|
||||||
|
// regenerišemo QR za već generisanu tajnu (korisnik je video ovaj QR)
|
||||||
|
nazivFirme := podesavanja["naziv_firme"]
|
||||||
|
if nazivFirme == "" {
|
||||||
|
nazivFirme = "NTech"
|
||||||
|
}
|
||||||
|
uri, qr := auth.RegenerisiTotpQR(tajna, k.KorisnickoIme, nazivFirme)
|
||||||
|
|
||||||
|
podaci := podaciAdminProfil{
|
||||||
|
PodaciStranice: ps,
|
||||||
|
TotpURI: uri,
|
||||||
|
TotpTajna: tajna,
|
||||||
|
TotpQR: template.URL("data:image/png;base64," + qr),
|
||||||
|
Greska: "totp",
|
||||||
|
}
|
||||||
|
renderujAdminTemplate(w, "web/templates/stranice/admin_profil.html", podaci)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.KorisniciRepo.SacuvajTotpTajnu(r.Context(), k.ID, tajna); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/profil?sacuvano=totp", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminTotpDeaktivacija uklanja TOTP tajnu
|
||||||
|
func (h *Handler) AdminTotpDeaktivacija(w http.ResponseWriter, r *http.Request) {
|
||||||
|
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||||
|
if k == nil {
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.KorisniciRepo.SacuvajTotpTajnu(r.Context(), k.ID, ""); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/profil?greska=2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/profil?sacuvano=totp_off", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderujAdminTemplate(w http.ResponseWriter, stranica string, podaci any) {
|
||||||
|
tmpl, err := template.ParseFiles(
|
||||||
|
"web/templates/teme/podrazumevana/base.html",
|
||||||
|
"web/templates/komponente/sidebar.html",
|
||||||
|
"web/templates/komponente/topbar.html",
|
||||||
|
stranica,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
|
||||||
|
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBoolForm čita boolean vrednost iz forme
|
||||||
|
func parseBoolForm(s string) bool {
|
||||||
|
b, _ := strconv.ParseBool(s)
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
|
|
||||||
"ntech/internal/db"
|
"ntech/internal/db"
|
||||||
"ntech/internal/db/sqlite"
|
"ntech/internal/db/sqlite"
|
||||||
|
"ntech/internal/middleware"
|
||||||
|
"ntech/internal/model"
|
||||||
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler drži zavisnosti koje su potrebne svim handlerima
|
// Handler drži zavisnosti koje su potrebne svim handlerima
|
||||||
@@ -17,6 +20,8 @@ type Handler struct {
|
|||||||
KlijentiRepo db.KlijentRepository
|
KlijentiRepo db.KlijentRepository
|
||||||
ServisRepo db.ServisRepository
|
ServisRepo db.ServisRepository
|
||||||
ProdajaRepo db.ProdajaRepository
|
ProdajaRepo db.ProdajaRepository
|
||||||
|
KorisniciRepo db.KorisniciRepository
|
||||||
|
SesijeRepo db.SesijeRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// Novi kreira novi Handler sa datom bazom
|
// Novi kreira novi Handler sa datom bazom
|
||||||
@@ -30,5 +35,25 @@ func Novi(baza *sql.DB) *Handler {
|
|||||||
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
|
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
|
||||||
ServisRepo: sqlite.NoviServisRepo(baza),
|
ServisRepo: sqlite.NoviServisRepo(baza),
|
||||||
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
|
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
|
||||||
|
KorisniciRepo: sqlite.NoviKorisniciRepo(baza),
|
||||||
|
SesijeRepo: sqlite.NoviSesijeRepo(baza),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// popuniPodaciStranice popunjava zajednička polja stranice uključujući prijavljenog korisnika
|
||||||
|
func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]string) model.PodaciStranice {
|
||||||
|
ps := model.PodaciStranice{
|
||||||
|
Tema: podesavanja["tema"],
|
||||||
|
NazivFirme: podesavanja["naziv_firme"],
|
||||||
|
Podnazlov: podesavanja["podnazlov"],
|
||||||
|
LogoTip: podesavanja["logo_tip"],
|
||||||
|
LogoPutanja: podesavanja["logo_putanja"],
|
||||||
|
Korisnik: "Admin",
|
||||||
|
}
|
||||||
|
if k := middleware.KorisnikIzKonteksta(r.Context()); k != nil {
|
||||||
|
ps.Korisnik = k.KorisnickoIme
|
||||||
|
ps.KorisnikIme = k.KorisnickoIme
|
||||||
|
ps.KorisnikUloga = k.Uloga
|
||||||
|
}
|
||||||
|
return ps
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ntech/internal/db/sqlite"
|
||||||
|
"ntech/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var srpskaImenaMeseci = []string{
|
||||||
|
"", "Januar", "Februar", "Mart", "April", "Maj", "Jun",
|
||||||
|
"Jul", "Avgust", "Septembar", "Oktobar", "Novembar", "Decembar",
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatujMesec(yyyymm string) string {
|
||||||
|
var god, mes int
|
||||||
|
if _, err := fmt.Sscanf(yyyymm, "%d-%d", &god, &mes); err != nil || mes < 1 || mes > 12 {
|
||||||
|
return yyyymm
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %d", srpskaImenaMeseci[mes], god)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PodaciIzvestaja su podaci za stranicu izveštaja
|
||||||
|
type PodaciIzvestaja struct {
|
||||||
|
model.PodaciStranice
|
||||||
|
MesecniPrihodi []MesecniPrihod
|
||||||
|
GrafikonJSON template.JS
|
||||||
|
StariNalozi []StariNalog
|
||||||
|
TopArtikli []TopArtikal
|
||||||
|
TopKlijenti []TopKlijent
|
||||||
|
}
|
||||||
|
|
||||||
|
// MesecniPrihod drži prihod od prodaje i servisa za jedan mesec
|
||||||
|
type MesecniPrihod struct {
|
||||||
|
MesecPrikaz string
|
||||||
|
Prodaja float64
|
||||||
|
Servis float64
|
||||||
|
Ukupno float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// StariNalog je servisni nalog bez datuma završetka stariji od 14 dana
|
||||||
|
type StariNalog struct {
|
||||||
|
ID int64
|
||||||
|
BrojNaloga string
|
||||||
|
Uredjaj string
|
||||||
|
KlijentNaziv string
|
||||||
|
Status string
|
||||||
|
DatumPrijema string
|
||||||
|
DanaProslo int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopArtikal je artikal rangiran po prodatoj količini
|
||||||
|
type TopArtikal struct {
|
||||||
|
Rang int
|
||||||
|
Naziv string
|
||||||
|
Kategorija string
|
||||||
|
UkupnoKolicina int
|
||||||
|
UkupnoPrihod float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopKlijent je klijent rangiran po ukupnoj vrednosti naloga
|
||||||
|
type TopKlijent struct {
|
||||||
|
Rang int
|
||||||
|
Naziv string
|
||||||
|
BrojNaloga int
|
||||||
|
UkupnoVrednost float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Izvestaji renderuje stranicu sa izveštajima
|
||||||
|
func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
podesavanja, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- mesečni prihod: prodaja ---
|
||||||
|
prodajaPoMesecu := map[string]float64{}
|
||||||
|
prodajaRed, err := h.DB.QueryContext(ctx, `
|
||||||
|
SELECT substr(datum, 1, 7), SUM(ukupno)
|
||||||
|
FROM prodajni_nalozi
|
||||||
|
WHERE substr(datum, 1, 10) >= date('now', '-11 months', 'start of month')
|
||||||
|
GROUP BY substr(datum, 1, 7)`)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("izvestaji: prihod prodaja: %v", err)
|
||||||
|
} else {
|
||||||
|
defer prodajaRed.Close()
|
||||||
|
for prodajaRed.Next() {
|
||||||
|
var mesec string
|
||||||
|
var iznos float64
|
||||||
|
if err := prodajaRed.Scan(&mesec, &iznos); err == nil {
|
||||||
|
prodajaPoMesecu[mesec] = iznos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- mesečni prihod: servis ---
|
||||||
|
servisPoMesecu := map[string]float64{}
|
||||||
|
servisRed, err := h.DB.QueryContext(ctx, `
|
||||||
|
SELECT substr(datum_zavrsetka, 1, 7), SUM(cena_konacna)
|
||||||
|
FROM servisni_nalozi
|
||||||
|
WHERE datum_zavrsetka IS NOT NULL
|
||||||
|
AND substr(datum_zavrsetka, 1, 10) >= date('now', '-11 months', 'start of month')
|
||||||
|
GROUP BY substr(datum_zavrsetka, 1, 7)`)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("izvestaji: prihod servis: %v", err)
|
||||||
|
} else {
|
||||||
|
defer servisRed.Close()
|
||||||
|
for servisRed.Next() {
|
||||||
|
var mesec string
|
||||||
|
var iznos float64
|
||||||
|
if err := servisRed.Scan(&mesec, &iznos); err == nil {
|
||||||
|
servisPoMesecu[mesec] = iznos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gradimo niz za poslednjih 12 meseci (hronološki)
|
||||||
|
sada := time.Now()
|
||||||
|
var mesecniPrihodi []MesecniPrihod
|
||||||
|
var grafikonLabele []string
|
||||||
|
var grafikonProdaja []float64
|
||||||
|
var grafikonServis []float64
|
||||||
|
|
||||||
|
for i := 11; i >= 0; i-- {
|
||||||
|
t := sada.AddDate(0, -i, 0)
|
||||||
|
kljuc := t.Format("2006-01")
|
||||||
|
prod := prodajaPoMesecu[kljuc]
|
||||||
|
serv := servisPoMesecu[kljuc]
|
||||||
|
mesecniPrihodi = append(mesecniPrihodi, MesecniPrihod{
|
||||||
|
MesecPrikaz: formatujMesec(kljuc),
|
||||||
|
Prodaja: prod,
|
||||||
|
Servis: serv,
|
||||||
|
Ukupno: prod + serv,
|
||||||
|
})
|
||||||
|
grafikonLabele = append(grafikonLabele, formatujMesec(kljuc))
|
||||||
|
grafikonProdaja = append(grafikonProdaja, prod)
|
||||||
|
grafikonServis = append(grafikonServis, serv)
|
||||||
|
}
|
||||||
|
|
||||||
|
type grafikonPodaci struct {
|
||||||
|
Labele []string `json:"labele"`
|
||||||
|
Prodaja []float64 `json:"prodaja"`
|
||||||
|
Servis []float64 `json:"servis"`
|
||||||
|
}
|
||||||
|
jsonBytes, _ := json.Marshal(grafikonPodaci{
|
||||||
|
Labele: grafikonLabele,
|
||||||
|
Prodaja: grafikonProdaja,
|
||||||
|
Servis: grafikonServis,
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- stari otvoreni nalozi (>14 dana bez završetka) ---
|
||||||
|
stariRed, err := h.DB.QueryContext(ctx, `
|
||||||
|
SELECT sn.id, sn.broj_naloga, sn.uredjaj, sn.status, sn.datum_prijema,
|
||||||
|
COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '—') AS klijent_naziv
|
||||||
|
FROM servisni_nalozi sn
|
||||||
|
LEFT JOIN klijenti k ON k.id = sn.klijent_id
|
||||||
|
WHERE sn.datum_zavrsetka IS NULL
|
||||||
|
AND substr(sn.datum_prijema, 1, 10) <= date('now', '-14 days')
|
||||||
|
ORDER BY sn.datum_prijema ASC`)
|
||||||
|
|
||||||
|
var stariNalozi []StariNalog
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("izvestaji: stari nalozi: %v", err)
|
||||||
|
} else {
|
||||||
|
defer stariRed.Close()
|
||||||
|
for stariRed.Next() {
|
||||||
|
var sn StariNalog
|
||||||
|
var datumVreme time.Time
|
||||||
|
if err := stariRed.Scan(&sn.ID, &sn.BrojNaloga, &sn.Uredjaj, &sn.Status, &datumVreme, &sn.KlijentNaziv); err == nil {
|
||||||
|
sn.DatumPrijema = datumVreme.Format("02.01.2006.")
|
||||||
|
sn.DanaProslo = int(time.Since(datumVreme).Hours() / 24)
|
||||||
|
stariNalozi = append(stariNalozi, sn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- top 10 najprodavanijih artikala ---
|
||||||
|
artRed, err := h.DB.QueryContext(ctx, `
|
||||||
|
SELECT a.naziv, COALESCE(k.naziv, '—'), SUM(sp.kolicina), SUM(sp.ukupno)
|
||||||
|
FROM stavke_prodaje sp
|
||||||
|
JOIN artikli a ON a.id = sp.artikal_id
|
||||||
|
LEFT JOIN kategorije k ON k.id = a.kategorija_id
|
||||||
|
GROUP BY sp.artikal_id
|
||||||
|
ORDER BY SUM(sp.kolicina) DESC
|
||||||
|
LIMIT 10`)
|
||||||
|
|
||||||
|
var topArtikli []TopArtikal
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("izvestaji: top artikli: %v", err)
|
||||||
|
} else {
|
||||||
|
defer artRed.Close()
|
||||||
|
for artRed.Next() {
|
||||||
|
var a TopArtikal
|
||||||
|
if err := artRed.Scan(&a.Naziv, &a.Kategorija, &a.UkupnoKolicina, &a.UkupnoPrihod); err == nil {
|
||||||
|
a.Rang = len(topArtikli) + 1
|
||||||
|
topArtikli = append(topArtikli, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- top 10 klijenata po ukupnoj vrednosti ---
|
||||||
|
klijRed, err := h.DB.QueryContext(ctx, `
|
||||||
|
SELECT
|
||||||
|
COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, ''))) AS naziv,
|
||||||
|
COALESCE(p.ukupno_prodaja, 0) + COALESCE(s.ukupno_servis, 0) AS ukupno_vrednost,
|
||||||
|
COALESCE(p.broj_prodaja, 0) + COALESCE(s.broj_servisa, 0) AS broj_naloga
|
||||||
|
FROM klijenti k
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT klijent_id, SUM(ukupno) AS ukupno_prodaja, COUNT(*) AS broj_prodaja
|
||||||
|
FROM prodajni_nalozi GROUP BY klijent_id
|
||||||
|
) p ON p.klijent_id = k.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT klijent_id, SUM(cena_konacna) AS ukupno_servis, COUNT(*) AS broj_servisa
|
||||||
|
FROM servisni_nalozi WHERE cena_konacna IS NOT NULL GROUP BY klijent_id
|
||||||
|
) s ON s.klijent_id = k.id
|
||||||
|
WHERE COALESCE(p.ukupno_prodaja, 0) + COALESCE(s.ukupno_servis, 0) > 0
|
||||||
|
ORDER BY ukupno_vrednost DESC
|
||||||
|
LIMIT 10`)
|
||||||
|
|
||||||
|
var topKlijenti []TopKlijent
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("izvestaji: top klijenti: %v", err)
|
||||||
|
} else {
|
||||||
|
defer klijRed.Close()
|
||||||
|
for klijRed.Next() {
|
||||||
|
var k TopKlijent
|
||||||
|
if err := klijRed.Scan(&k.Naziv, &k.UkupnoVrednost, &k.BrojNaloga); err == nil {
|
||||||
|
k.Rang = len(topKlijenti) + 1
|
||||||
|
topKlijenti = append(topKlijenti, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
MesecniPrihodi: mesecniPrihodi,
|
||||||
|
GrafikonJSON: template.JS(jsonBytes),
|
||||||
|
StariNalozi: stariNalozi,
|
||||||
|
TopArtikli: topArtikli,
|
||||||
|
TopKlijenti: topKlijenti,
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.ParseFiles(
|
||||||
|
"web/templates/teme/podrazumevana/base.html",
|
||||||
|
"web/templates/komponente/sidebar.html",
|
||||||
|
"web/templates/komponente/topbar.html",
|
||||||
|
"web/templates/stranice/izvestaji.html",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "base", podaci); err != nil {
|
||||||
|
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"ntech/internal/db/sqlite"
|
"ntech/internal/db/sqlite"
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
@@ -101,6 +104,22 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/podesavanja?sacuvano=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/podesavanja?sacuvano=1", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BackupBaze kreira konzistentnu kopiju baze i šalje je kao attachment
|
||||||
|
func (h *Handler) BackupBaze(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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 {
|
||||||
|
http.Error(w, "Greška pri kreiranju rezervne kopije", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(privremeni)
|
||||||
|
|
||||||
|
ime := fmt.Sprintf("ntech_backup_%s.db", time.Now().Format("20060102"))
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename=\""+ime+"\"")
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
http.ServeFile(w, r, privremeni)
|
||||||
|
}
|
||||||
|
|
||||||
// PromeniTemu menja aktivnu temu i vraća na prethodnu stranicu
|
// PromeniTemu menja aktivnu temu i vraća na prethodnu stranicu
|
||||||
func (h *Handler) PromeniTemu(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) PromeniTemu(w http.ResponseWriter, r *http.Request) {
|
||||||
tema := chi.URLParam(r, "tema")
|
tema := chi.URLParam(r, "tema")
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ntech/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
const imeKolacica = "ntech_sesija"
|
||||||
|
const trajanjeSeije = 7 * 24 * time.Hour
|
||||||
|
const trajanjePredSeije = 5 * time.Minute
|
||||||
|
|
||||||
|
// PrikazPrijave renderuje formu za prijavu
|
||||||
|
func (h *Handler) PrikazPrijave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// ako nema korisnika, preusmeri na setup wizard
|
||||||
|
postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context())
|
||||||
|
if err == nil && !postoji {
|
||||||
|
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
greska := r.URL.Query().Get("greska")
|
||||||
|
renderujStandaloneTemplate(w, "web/templates/stranice/prijava.html", map[string]any{
|
||||||
|
"Greska": greska,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prijava obrađuje POST formu za prijavu
|
||||||
|
func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
korisnickoIme := r.FormValue("korisnicko_ime")
|
||||||
|
lozinka := r.FormValue("lozinka")
|
||||||
|
|
||||||
|
korisnik, err := h.KorisniciRepo.DohvatiPoImenu(r.Context(), korisnickoIme)
|
||||||
|
if err != nil || !korisnik.Aktivan {
|
||||||
|
http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !auth.ProveriLozinku(korisnik.LozinkaHash, lozinka) {
|
||||||
|
http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := auth.GenerisiToken()
|
||||||
|
|
||||||
|
if korisnik.TotpTajna != "" {
|
||||||
|
// kreira privremenu sesiju koja čeka TOTP verifikaciju
|
||||||
|
if err := h.SesijeRepo.Kreiraj(r.Context(), korisnik.ID, token, time.Now().Add(trajanjePredSeije), false); err != nil {
|
||||||
|
http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, napraviKolacic(token, time.Now().Add(trajanjePredSeije)))
|
||||||
|
http.Redirect(w, r, "/prijava/totp", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// direktna sesija bez TOTP
|
||||||
|
if err := h.SesijeRepo.Kreiraj(r.Context(), korisnik.ID, token, time.Now().Add(trajanjeSeije), true); err != nil {
|
||||||
|
http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, napraviKolacic(token, time.Now().Add(trajanjeSeije)))
|
||||||
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrikazTotp renderuje formu za unos TOTP koda
|
||||||
|
func (h *Handler) PrikazTotp(w http.ResponseWriter, r *http.Request) {
|
||||||
|
kolacic, err := r.Cookie(imeKolacica)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sesija, err := h.SesijeRepo.DohvatiPoTokenu(r.Context(), kolacic.Value)
|
||||||
|
if err != nil || sesija.TotpPotvrdjeno || time.Now().After(sesija.DatumIsteka) {
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
greska := r.URL.Query().Get("greska")
|
||||||
|
renderujStandaloneTemplate(w, "web/templates/stranice/totp_provera.html", map[string]any{
|
||||||
|
"Greska": greska,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifikujTotp obrađuje POST formu za TOTP verifikaciju
|
||||||
|
func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) {
|
||||||
|
kolacic, err := r.Cookie(imeKolacica)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sesija, err := h.SesijeRepo.DohvatiPoTokenu(r.Context(), kolacic.Value)
|
||||||
|
if err != nil || sesija.TotpPotvrdjeno || time.Now().After(sesija.DatumIsteka) {
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
korisnik, err := h.KorisniciRepo.DohvatiPoID(r.Context(), sesija.KorisnikID)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
kod := r.FormValue("kod")
|
||||||
|
if !auth.VerifikujTotpKod(kod, korisnik.TotpTajna) {
|
||||||
|
http.Redirect(w, r, "/prijava/totp?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
novoIstice := time.Now().Add(trajanjeSeije)
|
||||||
|
if err := h.SesijeRepo.PotvrdiTotp(r.Context(), kolacic.Value, novoIstice); err != nil {
|
||||||
|
http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, napraviKolacic(kolacic.Value, novoIstice))
|
||||||
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrikazSetupa renderuje setup wizard za prvog korisnika
|
||||||
|
func (h *Handler) PrikazSetupa(w http.ResponseWriter, r *http.Request) {
|
||||||
|
postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context())
|
||||||
|
if err == nil && postoji {
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
greska := r.URL.Query().Get("greska")
|
||||||
|
renderujStandaloneTemplate(w, "web/templates/stranice/setup.html", map[string]any{
|
||||||
|
"Greska": greska,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SacuvajSetup kreira prvog superadmin korisnika
|
||||||
|
func (h *Handler) SacuvajSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context())
|
||||||
|
if err == nil && postoji {
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/setup?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ime := r.FormValue("korisnicko_ime")
|
||||||
|
lozinka := r.FormValue("lozinka")
|
||||||
|
potvrda := r.FormValue("lozinka_potvrda")
|
||||||
|
|
||||||
|
if len(ime) < 3 || len(lozinka) < 8 || lozinka != potvrda {
|
||||||
|
http.Redirect(w, r, "/setup?greska=1", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := auth.HashujLozinku(lozinka)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/setup?greska=2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.KorisniciRepo.Kreiraj(r.Context(), ime, hash, "superadmin"); err != nil {
|
||||||
|
http.Redirect(w, r, "/setup?greska=2", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/prijava?sacuvano=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Odjava briše sesiju i kolačić
|
||||||
|
func (h *Handler) Odjava(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if kolacic, err := r.Cookie(imeKolacica); err == nil {
|
||||||
|
_ = h.SesijeRepo.Obrisi(r.Context(), kolacic.Value)
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: imeKolacica,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func napraviKolacic(token string, istice time.Time) *http.Cookie {
|
||||||
|
return &http.Cookie{
|
||||||
|
Name: imeKolacica,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
Expires: istice,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderujStandaloneTemplate(w http.ResponseWriter, putanja string, podaci any) {
|
||||||
|
tmpl, err := template.ParseFiles(putanja)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tmpl.Execute(w, podaci); err != nil {
|
||||||
|
http.Error(w, "Greška pri prikazu stranice", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ntech/internal/db/sqlite"
|
||||||
|
"ntech/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type kontekstKljuc string
|
||||||
|
|
||||||
|
// KljucKorisnika je ključ za korisnika u request contextu
|
||||||
|
const KljucKorisnika kontekstKljuc = "korisnik"
|
||||||
|
|
||||||
|
// RequireAuth je chi middleware koji proverava sesiju i injektuje korisnika u context
|
||||||
|
func RequireAuth(db *sql.DB) func(http.Handler) http.Handler {
|
||||||
|
sesijeRepo := sqlite.NoviSesijeRepo(db)
|
||||||
|
korisRepo := sqlite.NoviKorisniciRepo(db)
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
kolacic, err := r.Cookie("ntech_sesija")
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sesija, err := sesijeRepo.DohvatiPoTokenu(r.Context(), kolacic.Value)
|
||||||
|
if err != nil {
|
||||||
|
// nevažeći token — briši kolačić i preusmeri
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "ntech_sesija",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// proveri da li je sesija istekla
|
||||||
|
if time.Now().After(sesija.DatumIsteka) {
|
||||||
|
_ = sesijeRepo.Obrisi(r.Context(), kolacic.Value)
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ako čeka TOTP verifikaciju, preusmeri na TOTP stranicu
|
||||||
|
if !sesija.TotpPotvrdjeno {
|
||||||
|
http.Redirect(w, r, "/prijava/totp", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
korisnik, err := korisRepo.DohvatiPoID(r.Context(), sesija.KorisnikID)
|
||||||
|
if err != nil || !korisnik.Aktivan {
|
||||||
|
_ = sesijeRepo.Obrisi(r.Context(), kolacic.Value)
|
||||||
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), KljucKorisnika, korisnik)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KorisnikIzKonteksta vraća trenutno prijavljenog korisnika iz konteksta
|
||||||
|
func KorisnikIzKonteksta(ctx context.Context) *model.Korisnik {
|
||||||
|
k, _ := ctx.Value(KljucKorisnika).(*model.Korisnik)
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// JeAdmin proverava da li korisnik ima admin ili superadmin ulogu
|
||||||
|
func JeAdmin(k *model.Korisnik) bool {
|
||||||
|
if k == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return k.Uloga == "admin" || k.Uloga == "superadmin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNijePrijavljen se vraća kada korisnik nije u contextu
|
||||||
|
var ErrNijePrijavljen = errors.New("korisnik nije prijavljen")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Korisnik predstavlja nalog korisnika u sistemu
|
||||||
|
type Korisnik struct {
|
||||||
|
ID int64
|
||||||
|
KorisnickoIme string
|
||||||
|
LozinkaHash string
|
||||||
|
Uloga string // "superadmin" | "admin" | "radnik"
|
||||||
|
Aktivan bool
|
||||||
|
TotpTajna string
|
||||||
|
DatumKreiranja time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sesija predstavlja aktivnu sesiju prijavljenog korisnika
|
||||||
|
type Sesija struct {
|
||||||
|
ID int64
|
||||||
|
KorisnikID int64
|
||||||
|
Token string
|
||||||
|
TotpPotvrdjeno bool
|
||||||
|
DatumIsteka time.Time
|
||||||
|
DatumKreiranja time.Time
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ type PodaciStranice struct {
|
|||||||
LogoTip string // "ikonica", "tekst", "slika"
|
LogoTip string // "ikonica", "tekst", "slika"
|
||||||
LogoPutanja string // putanja do slike, koristi se samo kada je LogoTip "slika"
|
LogoPutanja string // putanja do slike, koristi se samo kada je LogoTip "slika"
|
||||||
Korisnik string
|
Korisnik string
|
||||||
|
KorisnikIme string // korisničko ime prijavljenog korisnika
|
||||||
|
KorisnikUloga string // uloga: "superadmin", "admin", "radnik"
|
||||||
}
|
}
|
||||||
|
|
||||||
// PodaciDashboarda su podaci specifični za dashboard stranicu
|
// PodaciDashboarda su podaci specifični za dashboard stranicu
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS korisnici (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
korisnicko_ime TEXT NOT NULL UNIQUE,
|
||||||
|
lozinka_hash TEXT NOT NULL,
|
||||||
|
uloga TEXT NOT NULL DEFAULT 'radnik',
|
||||||
|
aktivan INTEGER NOT NULL DEFAULT 1,
|
||||||
|
totp_tajna TEXT,
|
||||||
|
datum_kreiranja DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS sesije (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
korisnik_id INTEGER NOT NULL REFERENCES korisnici(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
totp_potvrdjeno INTEGER NOT NULL DEFAULT 1,
|
||||||
|
datum_isteka DATETIME NOT NULL,
|
||||||
|
datum_kreiranja DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -81,6 +81,23 @@
|
|||||||
<span class="nav-tooltip">Izveštaji</span>
|
<span class="nav-tooltip">Izveštaji</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-separator"></div>
|
||||||
|
<div class="nav-oznaka">Nalog</div>
|
||||||
|
|
||||||
|
{{if or (eq .KorisnikUloga "superadmin") (eq .KorisnikUloga "admin")}}
|
||||||
|
<a href="/admin/korisnici" class="nav-stavka {{if eq .Stranica "admin"}}aktivan{{end}}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
<span>Korisnici</span>
|
||||||
|
<span class="nav-tooltip">Korisnici</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<a href="/admin/profil" class="nav-stavka">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
<span>Moj profil</span>
|
||||||
|
<span class="nav-tooltip">Moj profil</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="nav-separator"></div>
|
<div class="nav-separator"></div>
|
||||||
<div class="nav-oznaka">Sistem</div>
|
<div class="nav-oznaka">Sistem</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "naslov"}}Korisnici — NTech{{end}}
|
||||||
|
|
||||||
|
{{define "sadrzaj"}}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:16px;">
|
||||||
|
|
||||||
|
{{if .Sacuvano}}
|
||||||
|
<div class="poruka-uspeh">Promene su uspešno sačuvane.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if eq .Greska "1"}}
|
||||||
|
<div class="poruka-greska">Proverite unete podatke.</div>
|
||||||
|
{{else if eq .Greska "2"}}
|
||||||
|
<div class="poruka-greska">Greška pri čuvanju. Pokušajte ponovo.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- lista korisnika -->
|
||||||
|
<div class="kartica" style="padding:0;overflow:hidden;">
|
||||||
|
<div style="padding:16px 20px;border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Korisnici sistema</span>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<th style="padding:10px 20px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Korisničko ime</th>
|
||||||
|
<th style="padding:10px 20px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Uloga</th>
|
||||||
|
<th style="padding:10px 20px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Status</th>
|
||||||
|
<th style="padding:10px 20px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">2FA</th>
|
||||||
|
<th style="padding:10px 20px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Kreiran</th>
|
||||||
|
<th style="padding:10px 20px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Akcije</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Korisnici}}
|
||||||
|
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<td style="padding:10px 20px;font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{.KorisnickoIme}}</td>
|
||||||
|
<td style="padding:10px 20px;">
|
||||||
|
{{if eq $.KorisnikUloga "superadmin"}}
|
||||||
|
<form method="POST" action="/admin/korisnici/{{.ID}}/uloga" style="display:inline;">
|
||||||
|
<select name="uloga" onchange="this.form.submit()"
|
||||||
|
style="padding:4px 8px;border:0.5px solid var(--ivica);border-radius:6px;background:var(--pozadina);color:var(--tekst-glavni);font-size:12px;">
|
||||||
|
<option value="superadmin" {{if eq .Uloga "superadmin"}}selected{{end}}>Superadmin</option>
|
||||||
|
<option value="admin" {{if eq .Uloga "admin"}}selected{{end}}>Admin</option>
|
||||||
|
<option value="radnik" {{if eq .Uloga "radnik"}}selected{{end}}>Radnik</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<span style="font-size:13px;color:var(--tekst-sporedni);">
|
||||||
|
{{if eq .Uloga "superadmin"}}Superadmin{{else if eq .Uloga "admin"}}Admin{{else}}Radnik{{end}}
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 20px;text-align:center;">
|
||||||
|
{{if .Aktivan}}
|
||||||
|
<span style="display:inline-block;padding:2px 10px;border-radius:20px;background:#f0fdf4;color:#16a34a;font-size:11px;font-weight:500;">Aktivan</span>
|
||||||
|
{{else}}
|
||||||
|
<span style="display:inline-block;padding:2px 10px;border-radius:20px;background:#fef2f2;color:#dc2626;font-size:11px;font-weight:500;">Deaktiviran</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 20px;text-align:center;">
|
||||||
|
{{if .TotpTajna}}
|
||||||
|
<span style="font-size:11px;color:#16a34a;">✓ Uključena</span>
|
||||||
|
{{else}}
|
||||||
|
<span style="font-size:11px;color:var(--tekst-sporedni);">—</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 20px;font-size:13px;color:var(--tekst-sporedni);">
|
||||||
|
{{.DatumKreiranja.Format "02.01.2006."}}
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 20px;text-align:right;">
|
||||||
|
{{if ne .KorisnickoIme $.KorisnikIme}}
|
||||||
|
<form method="POST" action="/admin/korisnici/{{.ID}}/aktivan" style="display:inline;">
|
||||||
|
<button type="submit"
|
||||||
|
style="padding:5px 12px;border:0.5px solid var(--ivica);border-radius:6px;background:transparent;color:var(--tekst-sporedni);font-size:12px;cursor:pointer;">
|
||||||
|
{{if .Aktivan}}Deaktiviraj{{else}}Aktiviraj{{end}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- forma za novog korisnika -->
|
||||||
|
<div class="kartica">
|
||||||
|
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
|
||||||
|
Novi korisnik
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/korisnici/novi">
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:14px;margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Korisničko ime</label>
|
||||||
|
<input type="text" name="korisnicko_ime" required minlength="3"
|
||||||
|
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Lozinka</label>
|
||||||
|
<input type="password" name="lozinka" required minlength="8"
|
||||||
|
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Uloga</label>
|
||||||
|
<select name="uloga"
|
||||||
|
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
|
||||||
|
<option value="radnik">Radnik</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
{{if eq .KorisnikUloga "superadmin"}}<option value="superadmin">Superadmin</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
style="padding:9px 20px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;">
|
||||||
|
Dodaj korisnika
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "naslov"}}Moj profil — NTech{{end}}
|
||||||
|
|
||||||
|
{{define "dodatni-css"}}{{end}}
|
||||||
|
|
||||||
|
{{define "sadrzaj"}}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:16px;max-width:560px;">
|
||||||
|
|
||||||
|
{{if eq .Sacuvano "lozinka"}}
|
||||||
|
<div class="poruka-uspeh">Lozinka je uspešno promenjena.</div>
|
||||||
|
{{else if eq .Sacuvano "totp"}}
|
||||||
|
<div class="poruka-uspeh">Dvostepena verifikacija je uspešno uključena.</div>
|
||||||
|
{{else if eq .Sacuvano "totp_off"}}
|
||||||
|
<div class="poruka-uspeh">Dvostepena verifikacija je isključena.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- promena lozinke -->
|
||||||
|
<div class="kartica">
|
||||||
|
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
|
||||||
|
Promena lozinke
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if eq .Greska "lozinka"}}
|
||||||
|
<div class="poruka-greska" style="margin-bottom:14px;">Stara lozinka nije ispravna.</div>
|
||||||
|
{{else if eq .Greska "lozinka2"}}
|
||||||
|
<div class="poruka-greska" style="margin-bottom:14px;">Nova lozinka mora imati najmanje 8 karaktera i lozinke moraju biti iste.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="/admin/profil/lozinka">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Trenutna lozinka</label>
|
||||||
|
<input type="password" name="stara_lozinka" required
|
||||||
|
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Nova lozinka</label>
|
||||||
|
<input type="password" name="nova_lozinka" required minlength="8"
|
||||||
|
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Potvrda nove lozinke</label>
|
||||||
|
<input type="password" name="nova_lozinka_potvrda" required
|
||||||
|
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit"
|
||||||
|
style="padding:9px 20px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;">
|
||||||
|
Sačuvaj novu lozinku
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOTP / 2FA -->
|
||||||
|
<div class="kartica">
|
||||||
|
<div style="font-size:15px;font-weight:500;color:var(--tekst-glavni);margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
|
||||||
|
Dvostepena verifikacija (2FA)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .TotpURI}}
|
||||||
|
<!-- postavljanje TOTP -->
|
||||||
|
{{if eq .Greska "totp"}}
|
||||||
|
<div class="poruka-greska" style="margin-bottom:14px;">Neispravan kod. Pokušajte ponovo.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p style="font-size:13px;color:var(--tekst-sporedni);margin-bottom:16px;line-height:1.5;">
|
||||||
|
Skenirajte QR kod u aplikaciji (Google Authenticator, Authy...), pa unesite generisani kod da potvrdite podešavanje.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="text-align:center;margin-bottom:16px;">
|
||||||
|
<img src="{{.TotpQR}}" alt="QR kod" style="width:200px;height:200px;border-radius:8px;display:block;margin:0 auto;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details style="margin-bottom:16px;">
|
||||||
|
<summary style="font-size:12px;color:var(--tekst-sporedni);cursor:pointer;">Prikaži tajnu ručno</summary>
|
||||||
|
<code style="font-size:12px;background:var(--pozadina);padding:6px 10px;border-radius:6px;display:block;margin-top:8px;word-break:break-all;color:var(--tekst-glavni);">{{.TotpTajna}}</code>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<form method="POST" action="/admin/profil/totp/aktiviraj">
|
||||||
|
<input type="hidden" name="totp_tajna" value="{{.TotpTajna}}">
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<label style="font-size:13px;color:var(--tekst-sporedni);display:block;margin-bottom:6px;">Verifikacioni kod</label>
|
||||||
|
<input type="text" name="kod" inputmode="numeric" pattern="[0-9]{6}"
|
||||||
|
maxlength="6" required autofocus
|
||||||
|
style="width:160px;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:18px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;text-align:center;letter-spacing:4px;">
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
style="padding:9px 20px;background:var(--sb-akcent);color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;">
|
||||||
|
Potvrdi i uključi 2FA
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{else if .TotpAktivan}}
|
||||||
|
<!-- 2FA je uključena -->
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:14px;color:#16a34a;margin-bottom:4px;font-weight:500;">✓ Dvostepena verifikacija je uključena</div>
|
||||||
|
<div style="font-size:13px;color:var(--tekst-sporedni);">Prijava zahteva TOTP kod pored lozinke.</div>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/profil/totp/deaktiviraj"
|
||||||
|
onsubmit="return confirm('Da li ste sigurni? Ovo će isključiti dvostepenu verifikaciju.')">
|
||||||
|
<button type="submit"
|
||||||
|
style="padding:9px 18px;background:#dc2626;color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;">
|
||||||
|
Deaktiviraj 2FA
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{else}}
|
||||||
|
<!-- 2FA nije uključena -->
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:14px;color:var(--tekst-glavni);margin-bottom:4px;">Status: <strong>Isključena</strong></div>
|
||||||
|
<div style="font-size:13px;color:var(--tekst-sporedni);">Preporučujemo uključivanje dvostepene verifikacije.</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/profil/totp/pokreni"
|
||||||
|
style="padding:9px 18px;background:var(--sb-akcent);color:#fff;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none;">
|
||||||
|
Podesi 2FA
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "naslov"}}Izveštaji — NTech{{end}}
|
||||||
|
|
||||||
|
{{define "dodatni-css"}}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||||
|
<style>
|
||||||
|
.izv-naslov {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--tekst-sporedni);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-red {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--ivica);
|
||||||
|
border-radius: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
left: 3px;
|
||||||
|
top: 3px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-slider { background: var(--sb-akcent); }
|
||||||
|
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(18px); }
|
||||||
|
|
||||||
|
.rang-broj {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--pozadina);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tekst-sporedni);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rang-broj.zlatni { background: #fef3c7; color: #92400e; }
|
||||||
|
|
||||||
|
.badge-dana {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-dana.upozorenje { background: #fffbeb; color: #b45309; }
|
||||||
|
.badge-dana.kritican { background: #fef2f2; color: #dc2626; }
|
||||||
|
</style>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "sadrzaj"}}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:20px;">
|
||||||
|
|
||||||
|
<!-- 1. mesečni prihod -->
|
||||||
|
<div class="kartica">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:4px;">
|
||||||
|
<div class="izv-naslov" style="margin-bottom:0;">Mesečni prihod — poslednjih 12 meseci</div>
|
||||||
|
<div class="toggle-red" style="margin-bottom:0;">
|
||||||
|
<span style="font-size:13px;color:var(--tekst-sporedni);">Grafikon</span>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="toggle-grafikon" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="grafikon-wrapper" style="margin:16px 0;position:relative;height:260px;">
|
||||||
|
<canvas id="grafikon-prihod"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<th style="padding:8px 12px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Mesec</th>
|
||||||
|
<th style="padding:8px 12px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Prodaja</th>
|
||||||
|
<th style="padding:8px 12px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Servis</th>
|
||||||
|
<th style="padding:8px 12px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Ukupno</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .MesecniPrihodi}}
|
||||||
|
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<td style="padding:8px 12px;font-size:13px;color:var(--tekst-glavni);">{{.MesecPrikaz}}</td>
|
||||||
|
<td style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">
|
||||||
|
{{if gt .Prodaja 0.0}}{{printf "%.0f" .Prodaja}} din{{else}}—{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">
|
||||||
|
{{if gt .Servis 0.0}}{{printf "%.0f" .Servis}} din{{else}}—{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px 12px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">
|
||||||
|
{{if gt .Ukupno 0.0}}{{printf "%.0f" .Ukupno}} din{{else}}—{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. stari otvoreni nalozi -->
|
||||||
|
<div class="kartica">
|
||||||
|
<div class="izv-naslov">Stari otvoreni nalozi — bez završetka duže od 14 dana</div>
|
||||||
|
{{if .StariNalozi}}
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<th style="padding:8px 12px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Nalog</th>
|
||||||
|
<th style="padding:8px 12px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Uređaj</th>
|
||||||
|
<th style="padding:8px 12px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Klijent</th>
|
||||||
|
<th style="padding:8px 12px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Status</th>
|
||||||
|
<th style="padding:8px 12px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Primljeno</th>
|
||||||
|
<th style="padding:8px 12px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Dana</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .StariNalozi}}
|
||||||
|
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<td style="padding:8px 12px;">
|
||||||
|
<a href="/servis/{{.ID}}" style="font-size:13px;font-family:monospace;color:var(--sb-akcent);text-decoration:none;">{{.BrojNaloga}}</a>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px 12px;font-size:13px;color:var(--tekst-glavni);">{{.Uredjaj}}</td>
|
||||||
|
<td style="padding:8px 12px;font-size:13px;color:var(--tekst-sporedni);">{{.KlijentNaziv}}</td>
|
||||||
|
<td style="padding:8px 12px;font-size:13px;color:var(--tekst-sporedni);">{{.Status}}</td>
|
||||||
|
<td style="padding:8px 12px;font-size:13px;color:var(--tekst-sporedni);">{{.DatumPrijema}}</td>
|
||||||
|
<td style="padding:8px 12px;text-align:right;">
|
||||||
|
{{if gt .DanaProslo 30}}
|
||||||
|
<span class="badge-dana kritican">{{.DanaProslo}} dana</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge-dana upozorenje">{{.DanaProslo}} dana</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div style="font-size:13px;color:var(--tekst-sporedni);padding:8px 0;">Nema zaostalih naloga. Sve je u redu.</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
<!-- 3. top 10 artikala -->
|
||||||
|
<div class="kartica">
|
||||||
|
<div class="izv-naslov">Najprodavaniji artikli — top 10</div>
|
||||||
|
{{if .TopArtikli}}
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:11px;font-weight:500;color:var(--tekst-sporedni);width:32px;">#</th>
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:11px;font-weight:500;color:var(--tekst-sporedni);">Artikal</th>
|
||||||
|
<th style="padding:6px 8px;text-align:right;font-size:11px;font-weight:500;color:var(--tekst-sporedni);width:60px;">Kom.</th>
|
||||||
|
<th style="padding:6px 8px;text-align:right;font-size:11px;font-weight:500;color:var(--tekst-sporedni);width:100px;">Prihod</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .TopArtikli}}
|
||||||
|
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<td style="padding:7px 8px;">
|
||||||
|
<span class="rang-broj {{if eq .Rang 1}}zlatni{{end}}">{{.Rang}}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:7px 8px;">
|
||||||
|
<div style="font-size:13px;color:var(--tekst-glavni);">{{.Naziv}}</div>
|
||||||
|
<div style="font-size:11px;color:var(--tekst-sporedni);margin-top:1px;">{{.Kategorija}}</div>
|
||||||
|
</td>
|
||||||
|
<td style="padding:7px 8px;text-align:right;font-size:13px;color:var(--tekst-glavni);">{{.UkupnoKolicina}}</td>
|
||||||
|
<td style="padding:7px 8px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.0f" .UkupnoPrihod}} din</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<div style="font-size:13px;color:var(--tekst-sporedni);padding:8px 0;">Nema podataka o prodaji.</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. top 10 klijenata -->
|
||||||
|
<div class="kartica">
|
||||||
|
<div class="izv-naslov">Najvažniji klijenti — top 10</div>
|
||||||
|
{{if .TopKlijenti}}
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:11px;font-weight:500;color:var(--tekst-sporedni);width:32px;">#</th>
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:11px;font-weight:500;color:var(--tekst-sporedni);">Klijent</th>
|
||||||
|
<th style="padding:6px 8px;text-align:right;font-size:11px;font-weight:500;color:var(--tekst-sporedni);width:60px;">Naloga</th>
|
||||||
|
<th style="padding:6px 8px;text-align:right;font-size:11px;font-weight:500;color:var(--tekst-sporedni);width:110px;">Vrednost</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .TopKlijenti}}
|
||||||
|
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||||
|
<td style="padding:7px 8px;">
|
||||||
|
<span class="rang-broj {{if eq .Rang 1}}zlatni{{end}}">{{.Rang}}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:7px 8px;font-size:13px;color:var(--tekst-glavni);">{{.Naziv}}</td>
|
||||||
|
<td style="padding:7px 8px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">{{.BrojNaloga}}</td>
|
||||||
|
<td style="padding:7px 8px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">{{printf "%.0f" .UkupnoVrednost}} din</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<div style="font-size:13px;color:var(--tekst-sporedni);padding:8px 0;">Nema podataka o klijentima.</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var podaci = {{.GrafikonJSON}};
|
||||||
|
|
||||||
|
var isDark = document.documentElement.getAttribute('data-tema') !== 'svetla';
|
||||||
|
var tekstBoja = getComputedStyle(document.documentElement).getPropertyValue('--tekst-sporedni').trim() || '#6b7280';
|
||||||
|
var ivicaBoja = getComputedStyle(document.documentElement).getPropertyValue('--ivica').trim() || '#e5e7eb';
|
||||||
|
|
||||||
|
var ctx = document.getElementById('grafikon-prihod').getContext('2d');
|
||||||
|
var grafikon = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: podaci.labele,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Prodaja',
|
||||||
|
data: podaci.prodaja,
|
||||||
|
backgroundColor: 'rgba(79, 126, 248, 0.75)',
|
||||||
|
borderRadius: 4,
|
||||||
|
borderSkipped: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Servis',
|
||||||
|
data: podaci.servis,
|
||||||
|
backgroundColor: 'rgba(22, 163, 74, 0.75)',
|
||||||
|
borderRadius: 4,
|
||||||
|
borderSkipped: false,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: { color: tekstBoja, font: { size: 12 }, boxWidth: 12, padding: 16 }
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(ctx) {
|
||||||
|
return ' ' + ctx.dataset.label + ': ' + Math.round(ctx.parsed.y).toLocaleString('sr-RS') + ' din';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: false,
|
||||||
|
ticks: { color: tekstBoja, font: { size: 11 } },
|
||||||
|
grid: { color: ivicaBoja }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
color: tekstBoja,
|
||||||
|
font: { size: 11 },
|
||||||
|
callback: function(v) { return Math.round(v).toLocaleString('sr-RS') + ' din'; }
|
||||||
|
},
|
||||||
|
grid: { color: ivicaBoja }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('toggle-grafikon').addEventListener('change', function() {
|
||||||
|
document.getElementById('grafikon-wrapper').style.display = this.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -117,7 +117,15 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Sistem</span>
|
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Sistem</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:13px;color:var(--tekst-sporedni);">Verzija programa: <span style="color:var(--tekst-glavni);font-weight:500;">1.0.0</span></div>
|
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;">
|
||||||
|
<div style="font-size:13px;color:var(--tekst-sporedni);">Verzija programa: <span style="color:var(--tekst-glavni);font-weight:500;">1.0.0</span></div>
|
||||||
|
<a href="/podesavanja/backup"
|
||||||
|
style="display:inline-flex;align-items:center;gap:6px;padding:8px 16px;background:var(--kartica);border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;color:var(--tekst-sporedni);text-decoration:none;transition:background 0.2s;"
|
||||||
|
onmouseover="this.style.background='var(--pozadina)'" onmouseout="this.style.background='var(--kartica)'">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
Preuzmi backup baze
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- dugme za čuvanje -->
|
<!-- dugme za čuvanje -->
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Prijava — NTech</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #0f1117;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.kartica {
|
||||||
|
background: #1a1d2e;
|
||||||
|
border: 0.5px solid #2a2d3e;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.logo-naziv {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
.logo-podnazlov {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.polje { margin-bottom: 16px; }
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #0f1117;
|
||||||
|
border: 0.5px solid #2a2d3e;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
input:focus { border-color: #4f7ef8; }
|
||||||
|
.dugme {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px;
|
||||||
|
background: #4f7ef8;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 8px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.dugme:hover { opacity: 0.88; }
|
||||||
|
.greska {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 0.5px solid #fca5a5;
|
||||||
|
color: #dc2626;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.uspeh {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 0.5px solid #86efac;
|
||||||
|
color: #16a34a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="kartica">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="logo-naziv">NTech</div>
|
||||||
|
<div class="logo-podnazlov">Sistem za upravljanje</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Prijava</h1>
|
||||||
|
|
||||||
|
{{if eq .Greska "1"}}
|
||||||
|
<div class="greska">Pogrešno korisničko ime ili lozinka.</div>
|
||||||
|
{{else if eq .Greska "2"}}
|
||||||
|
<div class="greska">Greška na serveru. Pokušajte ponovo.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Sacuvano}}
|
||||||
|
<div class="uspeh">Nalog je kreiran. Možete se prijaviti.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="/prijava">
|
||||||
|
<div class="polje">
|
||||||
|
<label for="korisnicko_ime">Korisničko ime</label>
|
||||||
|
<input type="text" id="korisnicko_ime" name="korisnicko_ime"
|
||||||
|
autocomplete="username" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="polje">
|
||||||
|
<label for="lozinka">Lozinka</label>
|
||||||
|
<input type="password" id="lozinka" name="lozinka"
|
||||||
|
autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="dugme">Prijavi se</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Podešavanje — NTech</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #0f1117;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.kartica {
|
||||||
|
background: #1a1d2e;
|
||||||
|
border: 0.5px solid #2a2d3e;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
.logo { text-align: center; margin-bottom: 32px; }
|
||||||
|
.logo-naziv { font-size: 22px; font-weight: 600; color: #fff; }
|
||||||
|
.logo-podnazlov { font-size: 13px; color: #6b7280; margin-top: 4px; }
|
||||||
|
h1 { font-size: 18px; font-weight: 600; color: #fff; margin-bottom: 6px; }
|
||||||
|
.opis { font-size: 13px; color: #9ca3af; margin-bottom: 24px; line-height: 1.5; }
|
||||||
|
.polje { margin-bottom: 16px; }
|
||||||
|
label { display: block; font-size: 13px; color: #9ca3af; margin-bottom: 6px; }
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #0f1117;
|
||||||
|
border: 0.5px solid #2a2d3e;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
input:focus { border-color: #4f7ef8; }
|
||||||
|
.dugme {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px;
|
||||||
|
background: #4f7ef8;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 8px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.dugme:hover { opacity: 0.88; }
|
||||||
|
.greska {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 0.5px solid #fca5a5;
|
||||||
|
color: #dc2626;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.napomena {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="kartica">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="logo-naziv">NTech</div>
|
||||||
|
<div class="logo-podnazlov">Prvo pokretanje</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Kreiranje admin naloga</h1>
|
||||||
|
<p class="opis">Dobrodošli. Kreirajte nalog superadmin korisnika koji će upravljati sistemom.</p>
|
||||||
|
|
||||||
|
{{if eq .Greska "1"}}
|
||||||
|
<div class="greska">Proverite unete podatke. Korisničko ime mora imati najmanje 3 karaktera, lozinka najmanje 8, a lozinke moraju biti iste.</div>
|
||||||
|
{{else if eq .Greska "2"}}
|
||||||
|
<div class="greska">Greška pri kreiranju naloga. Pokušajte ponovo.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="/setup">
|
||||||
|
<div class="polje">
|
||||||
|
<label for="korisnicko_ime">Korisničko ime</label>
|
||||||
|
<input type="text" id="korisnicko_ime" name="korisnicko_ime"
|
||||||
|
autocomplete="username" autofocus required minlength="3">
|
||||||
|
</div>
|
||||||
|
<div class="polje">
|
||||||
|
<label for="lozinka">Lozinka</label>
|
||||||
|
<input type="password" id="lozinka" name="lozinka"
|
||||||
|
autocomplete="new-password" required minlength="8">
|
||||||
|
<div class="napomena">Najmanje 8 karaktera</div>
|
||||||
|
</div>
|
||||||
|
<div class="polje">
|
||||||
|
<label for="lozinka_potvrda">Potvrda lozinke</label>
|
||||||
|
<input type="password" id="lozinka_potvrda" name="lozinka_potvrda"
|
||||||
|
autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="dugme">Kreiraj nalog i nastavi</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Dvostepena verifikacija — NTech</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #0f1117;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.kartica {
|
||||||
|
background: #1a1d2e;
|
||||||
|
border: 0.5px solid #2a2d3e;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
.logo { text-align: center; margin-bottom: 32px; }
|
||||||
|
.logo-naziv { font-size: 22px; font-weight: 600; color: #fff; }
|
||||||
|
.opis { font-size: 13px; color: #9ca3af; margin-bottom: 24px; line-height: 1.5; }
|
||||||
|
h1 { font-size: 18px; font-weight: 600; color: #fff; margin-bottom: 8px; }
|
||||||
|
.polje { margin-bottom: 16px; }
|
||||||
|
label { display: block; font-size: 13px; color: #9ca3af; margin-bottom: 6px; }
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #0f1117;
|
||||||
|
border: 0.5px solid #2a2d3e;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #fff;
|
||||||
|
outline: none;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 6px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
input:focus { border-color: #4f7ef8; }
|
||||||
|
.dugme {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px;
|
||||||
|
background: #4f7ef8;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 8px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.dugme:hover { opacity: 0.88; }
|
||||||
|
.greska {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 0.5px solid #fca5a5;
|
||||||
|
color: #dc2626;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.nazad {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nazad:hover { color: #9ca3af; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="kartica">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="logo-naziv">NTech</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Dvostepena verifikacija</h1>
|
||||||
|
<p class="opis">Unesite 6-cifreni kod iz vaše aplikacije za autentifikaciju.</p>
|
||||||
|
|
||||||
|
{{if eq .Greska "1"}}
|
||||||
|
<div class="greska">Neispravan kod. Pokušajte ponovo.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="/prijava/totp">
|
||||||
|
<div class="polje">
|
||||||
|
<label for="kod">Kod za verifikaciju</label>
|
||||||
|
<input type="text" id="kod" name="kod"
|
||||||
|
inputmode="numeric" pattern="[0-9]{6}"
|
||||||
|
maxlength="6" autocomplete="one-time-code" autofocus required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="dugme">Potvrdi</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a href="/odjava" class="nazad">← Nazad na prijavu</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user