Ispravka QR koda za 2FA — generisanje na serveru kao base64 PNG

This commit is contained in:
2026-06-02 22:29:53 +02:00
parent f918b76542
commit 2401f6d5ec
25 changed files with 2449 additions and 48 deletions
+93
View File
@@ -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)
}
+23
View File
@@ -2,6 +2,7 @@ package db
import (
"context"
"time"
"ntech/internal/model"
)
@@ -74,3 +75,25 @@ type ProdajaRepository interface {
Obrisi(ctx context.Context, id int64) 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
}
+135
View File
@@ -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
}
+74
View File
@@ -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
}
+356
View File
@@ -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
}
+25
View File
@@ -5,6 +5,9 @@ import (
"ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
"net/http"
)
// Handler drži zavisnosti koje su potrebne svim handlerima
@@ -17,6 +20,8 @@ type Handler struct {
KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository
ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
}
// Novi kreira novi Handler sa datom bazom
@@ -30,5 +35,25 @@ func Novi(baza *sql.DB) *Handler {
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
ServisRepo: sqlite.NoviServisRepo(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
}
+275
View File
@@ -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
}
}
+19
View File
@@ -1,8 +1,11 @@
package handler
import (
"fmt"
"html/template"
"net/http"
"os"
"time"
"ntech/internal/db/sqlite"
"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)
}
// 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
func (h *Handler) PromeniTemu(w http.ResponseWriter, r *http.Request) {
tema := chi.URLParam(r, "tema")
+212
View File
@@ -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)
}
}
+87
View File
@@ -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")
+24
View File
@@ -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
}
+2
View File
@@ -33,6 +33,8 @@ type PodaciStranice struct {
LogoTip string // "ikonica", "tekst", "slika"
LogoPutanja string // putanja do slike, koristi se samo kada je LogoTip "slika"
Korisnik string
KorisnikIme string // korisničko ime prijavljenog korisnika
KorisnikUloga string // uloga: "superadmin", "admin", "radnik"
}
// PodaciDashboarda su podaci specifični za dashboard stranicu